diff --git a/CLAUDE.md b/CLAUDE.md index b8d575c54..5268dd61d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,13 +58,10 @@ scripts/safe-cargo.sh +nightly fmt --all ## Coding Conventions -### Parameter ordering +### General rules -When a method takes `&AppContext` (or `Option<&AppContext>`), place it as the first parameter after `self`. Example: - -```rust -fn remove_selected_utxos(&mut self, context: Option<&AppContext>, selected: &BTreeMap<...>) -> Result<(), String> -``` +* When a method takes `&AppContext` (or `Option<&AppContext>`), place it as the first parameter after `self`. +* Screen constructors handle errors internally via `MessageBanner` and return `Self` with degraded state. Keep `create_screen()` clean — no error handling at callsites. ## Architecture Overview @@ -176,7 +173,9 @@ response.inner.update(&mut self.amount); ## Message Display -User-facing messages use `MessageBanner` (`src/ui/components/message_banner.rs`). Global banners are rendered centrally by `island_central_panel()` — `AppState::update()` sets them automatically for backend task results. Screens only override `display_message()` for side-effects. See the component's doc comments and `docs/ai-design/2026-02-17-unified-messages/` for details. +User-facing messages (errors, warnings, success, infos) use `MessageBanner` (`src/ui/components/message_banner.rs`). Global banners are rendered centrally by `island_central_panel()` — `AppState::update()` sets them automatically for backend task results. When using `MessageBanner::set_global()`, no guard is needed — it is idempotent and automatically logs at the appropriate level (error/warn/debug). Screens only override `display_message()` for side-effects. See the component's doc comments and `docs/ai-design/2026-02-17-unified-messages/` for details. + +**BannerHandle lifecycle**: Screens that run backend tasks typically store a `refresh_banner: Option` field. On task dispatch, set it via `MessageBanner::set_global()` with an info/progress message. In `display_message()` (called as a side-effect by AppState), dismiss the progress banner via `self.refresh_banner.take_and_clear()` (from `OptionBannerExt`). Simply setting the field to `None` would leak the banner — `take_and_clear()` removes it from the egui context. AppState handles displaying the actual result banner. ## Database diff --git a/docs/ai-design/2026-02-27-banner-review-fixes/pr-604-review-guide.md b/docs/ai-design/2026-02-27-banner-review-fixes/pr-604-review-guide.md new file mode 100644 index 000000000..24f06c062 --- /dev/null +++ b/docs/ai-design/2026-02-27-banner-review-fixes/pr-604-review-guide.md @@ -0,0 +1,119 @@ +# PR #604 — Manual Review Guide + +Key files for manual review: architectural decisions, reusable patterns, and behavioral changes. +Files that merely apply patterns defined here are excluded (~57 files). + +## 1. Core Infrastructure + +### [`src/ui/components/message_banner.rs`](https://github.com/dashpay/dash-evo-tool/pull/604/files#diff-5c97c5af2fd0a32afba3e66e5f6a5f7acafb9a73da0d3b89a92f8fc9fca06a35) + +Heart of the PR. All other changes depend on patterns defined here. + +| What | Lines | Link | +|---|---|---| +| `set_global` idempotency change — no longer resets existing banners | L292–333 | [L292](https://github.com/dashpay/dash-evo-tool/pull/604/files#diff-5c97c5af2fd0a32afba3e66e5f6a5f7acafb9a73da0d3b89a92f8fc9fca06a35R292) | +| `replace_global` docs + empty-text semantics | L338–391 | [L338](https://github.com/dashpay/dash-evo-tool/pull/604/files#diff-5c97c5af2fd0a32afba3e66e5f6a5f7acafb9a73da0d3b89a92f8fc9fca06a35R338) | +| `with_details` now takes `impl Debug` instead of `&str` | L190–210 | [L190](https://github.com/dashpay/dash-evo-tool/pull/604/files#diff-5c97c5af2fd0a32afba3e66e5f6a5f7acafb9a73da0d3b89a92f8fc9fca06a35R190) | +| `ResultBannerExt` — `or_show_error()` on `Result` | L715–740 | [L715](https://github.com/dashpay/dash-evo-tool/pull/604/files#diff-5c97c5af2fd0a32afba3e66e5f6a5f7acafb9a73da0d3b89a92f8fc9fca06a35R715) | +| `OptionBannerExt` — `or_show_error()` + `take_and_clear()` on `Option` | L742–770 | [L742](https://github.com/dashpay/dash-evo-tool/pull/604/files#diff-5c97c5af2fd0a32afba3e66e5f6a5f7acafb9a73da0d3b89a92f8fc9fca06a35R742) | +| SEC-003: Eviction log no longer includes message text | L327–330 | [L327](https://github.com/dashpay/dash-evo-tool/pull/604/files#diff-5c97c5af2fd0a32afba3e66e5f6a5f7acafb9a73da0d3b89a92f8fc9fca06a35R327) | + +**Review focus**: Verify the idempotency change in `set_global` (old behavior reset existing banners, new behavior is a no-op). This is a subtle semantic change that affects all callers. + +## 2. Behavioral / Architectural Decisions + +### [`src/ui/mod.rs` — ScreenLike trait](https://github.com/dashpay/dash-evo-tool/pull/604/files#diff-d64f1e2614b0de74ea77854bc2fb944deaaa5b40a4e37b91a81a94da9bd5ebf8R840) + +`display_task_result` default changed from showing "Success" banner to **no-op**. Breaking behavioral change — screens that relied on the default now silently swallow success results. + +| What | Lines | Link | +|---|---|---| +| `display_task_result` default → no-op | L840–857 | [L840](https://github.com/dashpay/dash-evo-tool/pull/604/files#diff-d64f1e2614b0de74ea77854bc2fb944deaaa5b40a4e37b91a81a94da9bd5ebf8R840) | + +**Review focus**: Verify that AppState now handles success banners centrally, and no screen was relying on the old default `display_message("Success", Success)`. + +### [`src/app.rs` — Connection banner + network switch](https://github.com/dashpay/dash-evo-tool/pull/604/files#diff-8c6f1be9c6b6eb6dc2c76e6a6f2706d76f81aad0ff222c5a9ef4eab78acee7b5) + +New `update_connection_banner()` state machine and `clear_all_global` on network switch. + +| What | Lines | Link | +|---|---|---| +| `previous_connection_state` + `connection_banner_handle` fields | L93–99 | [L93](https://github.com/dashpay/dash-evo-tool/pull/604/files#diff-8c6f1be9c6b6eb6dc2c76e6a6f2706d76f81aad0ff222c5a9ef4eab78acee7b5R93) | +| `clear_all_global` on network switch + INTENTIONAL comment | L875–896 | [L875](https://github.com/dashpay/dash-evo-tool/pull/604/files#diff-8c6f1be9c6b6eb6dc2c76e6a6f2706d76f81aad0ff222c5a9ef4eab78acee7b5R875) | +| `update_connection_banner()` — Disconnected/Syncing/Synced FSM | L898–934 | [L898](https://github.com/dashpay/dash-evo-tool/pull/604/files#diff-8c6f1be9c6b6eb6dc2c76e6a6f2706d76f81aad0ff222c5a9ef4eab78acee7b5R898) | + +**Review focus**: The FSM uses `OverallConnectionState` equality to avoid redundant banner updates. Verify the state transitions cover all edge cases (e.g., rapid Disconnected→Syncing→Disconnected). + +### [`src/context/connection_status.rs`](https://github.com/dashpay/dash-evo-tool/pull/604/files#diff-75d4306c0e7eca30d7e0dbb01b6f6b3d3b3af1e65a22e6de68aa6c92d9b3779f) + +Removed error-string-matching logic (`contains("Failed to get best chain lock...")`). Verify this dead code removal is safe — the connection banner in `app.rs` now handles disconnected state via the FSM instead. + +| What | Lines | Link | +|---|---|---| +| Removed string-matching error handler | L295–327 | [L295](https://github.com/dashpay/dash-evo-tool/pull/604/files#diff-75d4306c0e7eca30d7e0dbb01b6f6b3d3b3af1e65a22e6de68aa6c92d9b3779f) | + +## 3. Reusable Pattern Examples (one representative each) + +### [`src/ui/tokens/mod.rs` — Shared helpers](https://github.com/dashpay/dash-evo-tool/pull/604/files#diff-0e09ce3e1e56a10a8d2cef2e29e7addb8e5a4dff5f78b8e15c50cdc94ef53e06R16) + +Defines 3 shared helpers used by ~15 token screens. This is where the DRY pattern originates. + +| What | Lines | Link | +|---|---|---| +| `load_identities_with_banner()` | L22–28 | [L22](https://github.com/dashpay/dash-evo-tool/pull/604/files#diff-0e09ce3e1e56a10a8d2cef2e29e7addb8e5a4dff5f78b8e15c50cdc94ef53e06R22) | +| `set_error_banner()` | L31–34 | [L31](https://github.com/dashpay/dash-evo-tool/pull/604/files#diff-0e09ce3e1e56a10a8d2cef2e29e7addb8e5a4dff5f78b8e15c50cdc94ef53e06R31) | +| `validate_signing_key()` — `&Option` → `Option<&T>` | L40–56 | [L40](https://github.com/dashpay/dash-evo-tool/pull/604/files#diff-0e09ce3e1e56a10a8d2cef2e29e7addb8e5a4dff5f78b8e15c50cdc94ef53e06R40) | + +### [`src/ui/tokens/claim_tokens_screen.rs` — Constructor error handling](https://github.com/dashpay/dash-evo-tool/pull/604/files#diff-e5e6e8b6d4ffd3f1fba60f4bbb7c2e9a6a5db3b2aa3b0acfe7c4bc01fd2a88b5) + +Best example of the SEC-001 fix pattern — eliminated `.expect()` panics in constructors, replaced with `or_show_error()` + degraded state. + +| What | Lines | Link | +|---|---|---| +| Constructor: single DB load + `or_show_error` + `.or_else` fallback | L77–107 | [L77](https://github.com/dashpay/dash-evo-tool/pull/604/files#diff-e5e6e8b6d4ffd3f1fba60f4bbb7c2e9a6a5db3b2aa3b0acfe7c4bc01fd2a88b5R77) | +| Status enum: `WaitingForResult(u64)` → `WaitingForResult` (dropped timestamp) | L45–50 | [L45](https://github.com/dashpay/dash-evo-tool/pull/604/files#diff-e5e6e8b6d4ffd3f1fba60f4bbb7c2e9a6a5db3b2aa3b0acfe7c4bc01fd2a88b5R45) | + +### [`src/ui/identities/add_existing_identity_screen.rs` — `AddIdentityStatus::Error` variant](https://github.com/dashpay/dash-evo-tool/pull/604/files#diff-ce4b3e7e4e4beb1a3c39f4def2f6a86a86a3a3a97e64bbd29b9fa5f1db34c7e7) + +Shows the PROJ-005 pattern — status enums no longer carry error strings (moved to global banner). Also shows constructor error handling via `MessageBanner::set_global`. + +| What | Lines | Link | +|---|---|---| +| `AddIdentityStatus::Error` (unit variant, no string) | L85–88 | [L85](https://github.com/dashpay/dash-evo-tool/pull/604/files#diff-ce4b3e7e4e4beb1a3c39f4def2f6a86a86a3a3a97e64bbd29b9fa5f1db34c7e7R85) | +| Constructor: init error → `set_global` | L126–128 | [L126](https://github.com/dashpay/dash-evo-tool/pull/604/files#diff-ce4b3e7e4e4beb1a3c39f4def2f6a86a86a3a3a97e64bbd29b9fa5f1db34c7e7R126) | + +### [`src/ui/wallets/send_screen.rs` — BannerHandle lifecycle](https://github.com/dashpay/dash-evo-tool/pull/604/files#diff-4b2fd2b6b35fe1d83a5c0e1a80e8a51e03ba4c3f03e5da1bb7498fe43e34e57f) + +Most complete example of the `BannerHandle` lifecycle pattern — progress banner with elapsed time, clearing on result, and the `set_send_progress_banner` helper. + +| What | Lines | Link | +|---|---|---| +| `send_banner: Option` field | L389–392 | [L389](https://github.com/dashpay/dash-evo-tool/pull/604/files#diff-4b2fd2b6b35fe1d83a5c0e1a80e8a51e03ba4c3f03e5da1bb7498fe43e34e57fR389) | +| `set_send_progress_banner()` — take_and_clear + set_global + with_elapsed | L650–660 | [L650](https://github.com/dashpay/dash-evo-tool/pull/604/files#diff-4b2fd2b6b35fe1d83a5c0e1a80e8a51e03ba4c3f03e5da1bb7498fe43e34e57fR650) | + +## 4. Conventions / Docs + +### [`CLAUDE.md`](https://github.com/dashpay/dash-evo-tool/pull/604/files#diff-82d8aa1d8fd8d9b71c3cf5e7fcade1d8697cc47c6df2e1b0c77ed5b0f01e5e93) + +Updated conventions affect all future development. + +| What | Lines | Link | +|---|---|---| +| Constructor error handling convention | L58–63 | [L58](https://github.com/dashpay/dash-evo-tool/pull/604/files#diff-82d8aa1d8fd8d9b71c3cf5e7fcade1d8697cc47c6df2e1b0c77ed5b0f01e5e93R58) | +| `BannerHandle` lifecycle docs | L173–181 | [L173](https://github.com/dashpay/dash-evo-tool/pull/604/files#diff-82d8aa1d8fd8d9b71c3cf5e7fcade1d8697cc47c6df2e1b0c77ed5b0f01e5e93R173) | + +## Summary: 8 files to review, ~57 files skipped + +| Priority | File | What to verify | +|---|---|---| +| 🔴 | `message_banner.rs` | `set_global` idempotency change, extension traits | +| 🔴 | `src/ui/mod.rs` | `display_task_result` default → no-op | +| 🔴 | `src/app.rs` | Connection banner FSM, `clear_all_global` on switch | +| 🟡 | `connection_status.rs` | Error-string-matching removal safety | +| 🟡 | `tokens/mod.rs` | Shared helpers (`validate_signing_key` signature) | +| 🟢 | `claim_tokens_screen.rs` | Constructor pattern example | +| 🟢 | `add_existing_identity_screen.rs` | Status enum + init error pattern | +| 🟢 | `send_screen.rs` | BannerHandle lifecycle pattern | +| 📄 | `CLAUDE.md` | Convention accuracy | + +The remaining ~57 files are mechanical applications of these patterns — `take_and_clear()`, `or_show_error()`, status enum simplification, and `error_message` field removal. If the patterns above look correct, those files are correct by construction. diff --git a/src/app.rs b/src/app.rs index 937eb8c2a..1cae661f5 100644 --- a/src/app.rs +++ b/src/app.rs @@ -7,12 +7,13 @@ use crate::backend_task::error::TaskError; use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::components::core_zmq_listener::{CoreZMQListener, ZMQMessage}; use crate::context::AppContext; -use crate::context::connection_status::ConnectionStatus; +use crate::context::connection_status::{ConnectionStatus, OverallConnectionState}; use crate::database::Database; #[cfg(not(feature = "testing"))] use crate::logging::initialize_logger; use crate::model::settings::Settings; -use crate::ui::components::MessageBanner; +use crate::spv::CoreBackendMode; +use crate::ui::components::{BannerHandle, MessageBanner}; use crate::ui::contracts_documents::contracts_documents_screen::DocumentQueryScreen; use crate::ui::dashpay::{DashPayScreen, DashPaySubscreen, ProfileSearchScreen}; use crate::ui::dpns::dpns_contested_names_screen::{ @@ -92,6 +93,11 @@ pub struct AppState { pub show_welcome_screen: bool, /// The welcome screen instance (only created if needed) pub welcome_screen: Option, + /// Previous connection state, used to detect transitions and update banners. + /// `None` on startup / after network switch to force the first evaluation. + previous_connection_state: Option, + /// Handle to the current connection status banner, if one is displayed + connection_banner_handle: Option, } #[derive(Debug, Clone, PartialEq)] @@ -706,6 +712,8 @@ impl AppState { subtasks, show_welcome_screen: !onboarding_completed, welcome_screen: None, + previous_connection_state: None, + connection_banner_handle: None, }; // Initialize welcome screen if needed (after mainnet_app_context is owned by the struct) @@ -867,12 +875,89 @@ impl AppState { self.chosen_network = network; let app_context = self.current_app_context().clone(); + // INTENTIONAL(SEC-004): Clear stale banners from the previous network context. + // A backend task completing after the switch could set a new banner in the new + // network context — accepted risk for a local desktop app (cosmetic only). + MessageBanner::clear_all_global(app_context.egui_ctx()); + for screen in self.main_screens.values_mut() { screen.change_context(app_context.clone()) } self.connection_status .reset(app_context.core_backend_mode()); + + // Reset connection banner tracking so the next frame re-evaluates + // the new network's state (even if it matches the old state). + if let Some(handle) = self.connection_banner_handle.take() { + handle.clear(); + } + self.previous_connection_state = None; + } + + /// Update the connection status banner when the overall connection state + /// transitions between Disconnected, Connecting, Syncing, and Synced. + /// + /// Also re-evaluates the banner text while in `Connecting` state each frame + /// because the degraded-peer timeout can fire without a state transition. + fn update_connection_banner(&mut self, ctx: &egui::Context, app_context: &Arc) { + let connection_status = app_context.connection_status(); + let current_state = connection_status.overall_state(); + let state_changed = self.previous_connection_state != Some(current_state); + + // In Connecting state the banner text can change (normal → degraded) + // without a state transition, so we must re-evaluate every frame. + // For all other states, skip if nothing changed. + if !state_changed && current_state != OverallConnectionState::Connecting { + return; + } + + // Clear old banner on state transitions + if state_changed && let Some(handle) = self.connection_banner_handle.take() { + handle.clear(); + } + + // Display new banner based on current state + let backend_mode = connection_status.backend_mode(); + match current_state { + OverallConnectionState::Disconnected => { + let msg = match backend_mode { + CoreBackendMode::Rpc => "Disconnected — check that Dash Core is running", + CoreBackendMode::Spv => "Disconnected — check your internet connection", + }; + self.connection_banner_handle = + Some(MessageBanner::set_global(ctx, msg, MessageType::Error)); + } + OverallConnectionState::Connecting => { + // SPV active but no peers connected yet. The degraded flag + // flips after 30 s — `set_global` is idempotent for same text, + // so calling it every frame while Connecting is cheap. + let msg = if connection_status.spv_peer_degraded() { + "Having trouble finding peers. Check your connection." + } else { + "Looking for peers…" + }; + // Replace the banner when the text changes (normal → degraded). + if let Some(handle) = &self.connection_banner_handle { + handle.set_message(msg); + } else { + self.connection_banner_handle = + Some(MessageBanner::set_global(ctx, msg, MessageType::Warning)); + } + } + OverallConnectionState::Syncing => { + let msg = match backend_mode { + CoreBackendMode::Rpc => "Syncing with Dash Core…", + CoreBackendMode::Spv => "SPV sync in progress…", + }; + self.connection_banner_handle = + Some(MessageBanner::set_global(ctx, msg, MessageType::Warning)); + } + OverallConnectionState::Synced => { + // No banner needed for fully synced state + } + } + self.previous_connection_state = Some(current_state); } pub fn visible_screen_mut(&mut self) -> &mut Screen { @@ -942,9 +1027,12 @@ impl App for AppState { BackendTaskSuccessResult::Refresh => { self.visible_screen_mut().refresh(); } - BackendTaskSuccessResult::Message(ref _msg) => { - // Let the screen handle Message via display_task_result - // so it can do custom handling (like clearing spinners) + BackendTaskSuccessResult::Message(ref msg) => { + // TODO(RUST-002): Some screens inspect Message text for error + // keywords and may override with an Error banner, causing a + // brief green-then-red flash. Refactor to pass structured error + // types through task results instead of string messages. + MessageBanner::set_global(ctx, msg, MessageType::Success); self.visible_screen_mut() .display_task_result(unboxed_message); } @@ -988,7 +1076,11 @@ impl App for AppState { let msg = err.to_string(); let handle = MessageBanner::set_global(ctx, &msg, MessageType::Error); if self.current_app_context().is_developer_mode() { - handle.with_details(&format!("{err:?}")); + // INTENTIONAL(SEC-003): TaskError Debug output is shown to users + // in developer mode. This is a local UI app — no third parties + // see this output. Ensure inner error types don't expose secrets + // (see #667). + handle.with_details(&err); } self.visible_screen_mut() .display_message(&msg, MessageType::Error); @@ -1161,16 +1253,7 @@ impl App for AppState { .trigger_refresh(active_context.as_ref()), ); - // Show a warning banner when SPV has been unable to find peers - // for an extended period. Cleared as soon as the condition resolves. - const SPV_DEGRADED_BANNER: &str = "Having trouble finding peers. Check your connection."; - if active_context.core_backend_mode() == crate::spv::CoreBackendMode::Spv { - if active_context.connection_status().spv_peer_degraded() { - MessageBanner::set_global(ctx, SPV_DEGRADED_BANNER, MessageType::Warning); - } else { - MessageBanner::clear_global_message(ctx, SPV_DEGRADED_BANNER); - } - } + self.update_connection_banner(ctx, &active_context); for action in actions { match action { diff --git a/src/backend_task/core/mod.rs b/src/backend_task/core/mod.rs index 49ee83600..11890a9c6 100644 --- a/src/backend_task/core/mod.rs +++ b/src/backend_task/core/mod.rs @@ -185,19 +185,7 @@ impl AppContext { let devnet_chainlock = devnet_result.ok(); let local_chainlock = local_result.ok(); - // If all three failed, bail out with an error - if mainnet_chainlock.is_none() - && testnet_chainlock.is_none() - && devnet_chainlock.is_none() - && local_chainlock.is_none() - { - return Err( - "Failed to get best chain lock for mainnet, testnet, devnet, and local network" - .to_string(), - ); - } - - // Otherwise, return the successes we have + // Return whatever we have — even all-None is valid. Ok(BackendTaskSuccessResult::CoreItem(CoreItem::ChainLocks( mainnet_chainlock, testnet_chainlock, diff --git a/src/context/connection_status.rs b/src/context/connection_status.rs index 221f258b6..01d9129a0 100644 --- a/src/context/connection_status.rs +++ b/src/context/connection_status.rs @@ -346,9 +346,10 @@ impl ConnectionStatus { self.set_rpc_online(online); } + /// Updates internal connection state from a task result. pub fn handle_task_result(&self, task_result: &TaskResult, active_network: Network) { - match task_result { - TaskResult::Success(message) => match message.as_ref() { + if let TaskResult::Success(message) = task_result { + match message.as_ref() { BackendTaskSuccessResult::CoreItem(CoreItem::ChainLocks( mainnet_chainlock, testnet_chainlock, @@ -371,16 +372,7 @@ impl ConnectionStatus { } } _ => {} - }, - TaskResult::Error(err) => { - if err.to_string().contains( - "Failed to get best chain lock for mainnet, testnet, devnet, and local", - ) { - self.set_rpc_online(false); - self.refresh_state(); - } } - _ => {} } } diff --git a/src/ui/components/message_banner.rs b/src/ui/components/message_banner.rs index 2dc09e61a..c80e6a088 100644 --- a/src/ui/components/message_banner.rs +++ b/src/ui/components/message_banner.rs @@ -2,6 +2,7 @@ use crate::ui::MessageType; use crate::ui::components::component_trait::{Component, ComponentResponse}; use crate::ui::theme::{DashColors, Shape, Spacing, Typography}; use egui::InnerResponse; +use std::fmt; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{Duration, Instant}; use tracing::{debug, error, warn}; @@ -16,6 +17,9 @@ const DETAILS_MAX_HEIGHT: f32 = 120.0; static BANNER_KEY_COUNTER: AtomicU64 = AtomicU64::new(0); fn next_banner_key() -> u64 { + // Relaxed is sufficient: we only need uniqueness (monotonic counter), + // not ordering with other atomic operations. The counter runs in a + // single-threaded UI context. BANNER_KEY_COUNTER.fetch_add(1, Ordering::Relaxed) } @@ -129,6 +133,10 @@ impl BannerState { /// /// The handle is `'static` and safe to store. Methods that modify the banner /// (`set_message`, `with_auto_dismiss`) take `&self` so the handle can be reused. +/// +/// INTENTIONAL(SEC-004): BannerHandle is Send+Sync because egui::Context is +/// Send+Sync with internal locking. This is acceptable for a single-threaded +/// UI app; egui's own thread-safety guarantees apply. #[derive(Clone)] pub struct BannerHandle { ctx: egui::Context, @@ -148,7 +156,7 @@ impl BannerHandle { /// Update the display text of this banner. /// Returns `None` if the banner no longer exists. - pub fn set_message(&self, text: &str) -> Option<&Self> { + pub fn set_message(&self, text: impl fmt::Display) -> Option<&Self> { let mut banners = get_banners(&self.ctx); let b = banners.iter_mut().find(|b| b.key == self.key)?; b.text = text.to_string(); @@ -182,14 +190,21 @@ impl BannerHandle { /// Attach optional technical details to this banner. /// Details are shown in a collapsible section (collapsed by default). + /// + /// Accepts `impl Debug` (not `Display`) because callers typically pass + /// error types whose `Debug` representation includes structured context + /// (nested causes, variant names) that is more useful in a diagnostic + /// details pane than the single-line `Display` output. + /// /// Returns `None` if the banner no longer exists. - pub fn with_details(&self, details: &str) -> Option<&Self> { + pub fn with_details(&self, details: impl fmt::Debug) -> Option<&Self> { + let details = format!("{:?}", details); if details.is_empty() { return Some(self); } let mut banners = get_banners(&self.ctx); let b = banners.iter_mut().find(|b| b.key == self.key)?; - b.details = Some(details.to_string()); + b.details = Some(details); set_banners(&self.ctx, banners); Some(self) } @@ -197,13 +212,14 @@ impl BannerHandle { /// Attach an optional recovery suggestion to this banner. /// The suggestion is shown inline (visible without expanding). /// Returns `None` if the banner no longer exists. - pub fn with_suggestion(&self, suggestion: &str) -> Option<&Self> { + pub fn with_suggestion(&self, suggestion: impl fmt::Display) -> Option<&Self> { + let suggestion = suggestion.to_string(); if suggestion.is_empty() { return Some(self); } let mut banners = get_banners(&self.ctx); let b = banners.iter_mut().find(|b| b.key == self.key)?; - b.suggestion = Some(suggestion.to_string()); + b.suggestion = Some(suggestion); set_banners(&self.ctx, banners); Some(self) } @@ -236,15 +252,12 @@ impl MessageBanner { /// Sets or replaces the current message. Resets the auto-dismiss timer. /// An empty string is treated as a clear operation. - pub fn set_message(&mut self, text: &str, message_type: MessageType) -> &mut Self { + pub fn set_message(&mut self, text: impl fmt::Display, message_type: MessageType) -> &mut Self { + let text = text.to_string(); if text.is_empty() { self.state = None; } else { - self.state = Some(BannerState::new( - next_banner_key(), - text.to_string(), - message_type, - )); + self.state = Some(BannerState::new(next_banner_key(), text, message_type)); } self } @@ -293,9 +306,25 @@ impl MessageBanner { /// Evicts the oldest message when the cap ([`MAX_BANNERS`]) is reached. /// /// Returns a [`BannerHandle`] for updating or clearing the banner later. - pub fn set_global(ctx: &egui::Context, text: &str, message_type: MessageType) -> BannerHandle { + pub fn set_global( + ctx: &egui::Context, + text: impl fmt::Display, + message_type: MessageType, + ) -> BannerHandle { + let text = text.to_string(); let mut banners = get_banners(ctx); - if let Some(existing) = banners.iter().find(|b| b.text == text) { + if let Some(existing) = banners.iter_mut().find(|b| b.text == text) { + // Same text already displayed: update message_type if it changed, + // but preserve timestamps and auto-dismiss timer (idempotent for text). + if existing.message_type != message_type { + existing.message_type = message_type; + let key = existing.key; + set_banners(ctx, banners); + return BannerHandle { + ctx: ctx.clone(), + key, + }; + } return BannerHandle { ctx: ctx.clone(), key: existing.key, @@ -303,9 +332,13 @@ impl MessageBanner { } let key = next_banner_key(); if !text.is_empty() { - banners.push(BannerState::new(key, text.to_string(), message_type)); + banners.push(BannerState::new(key, text, message_type)); if banners.len() > MAX_BANNERS { - banners.remove(0); + let evicted = banners.remove(0); + warn!( + "Banner evicted (capacity {}): {:?}", + MAX_BANNERS, evicted.message_type, + ); } set_banners(ctx, banners); } @@ -316,17 +349,31 @@ impl MessageBanner { } /// Finds a message by `old_text` and replaces it with `new_text`. - /// If `old_text` is not found, adds `new_text` as a new message (with dedup check). + /// If `old_text` is not found, falls back to adding `new_text` as a new + /// message (with dedup check). This fallback is intentional: callers use + /// `replace_global` for progress updates where the previous banner may + /// have been dismissed or evicted, and the new message should still appear. + /// + /// If `old_text` is not found but `new_text` is already displayed, returns + /// a handle to the existing banner without resetting it (consistent with + /// [`Self::set_global`] idempotency). + /// + /// **Empty `new_text`**: clears the `old_text` banner (if present) and + /// returns a handle with a fresh key that does not correspond to any banner. + /// Subsequent calls on this handle (`set_message`, `with_details`, `clear`) + /// are safe no-ops returning `None`. /// /// Returns a [`BannerHandle`] for updating or clearing the banner later. pub fn replace_global( ctx: &egui::Context, - old_text: &str, - new_text: &str, + old_text: impl fmt::Display, + new_text: impl fmt::Display, message_type: MessageType, ) -> BannerHandle { + let old_text = old_text.to_string(); + let new_text = new_text.to_string(); if new_text.is_empty() { - Self::clear_global_message(ctx, old_text); + Self::clear_global_message(ctx, &old_text); return BannerHandle { ctx: ctx.clone(), key: next_banner_key(), @@ -336,15 +383,20 @@ impl MessageBanner { let key; if let Some(b) = banners.iter_mut().find(|b| b.text == old_text) { key = b.key; - b.reset_to(new_text.to_string(), message_type); - } else if let Some(existing) = banners.iter_mut().find(|b| b.text == new_text) { + b.reset_to(new_text, message_type); + } else if let Some(existing) = banners.iter().find(|b| b.text == new_text) { + // Idempotent: if new_text already displayed, return handle without + // resetting (consistent with set_global behavior). key = existing.key; - existing.reset_to(new_text.to_string(), message_type); } else { key = next_banner_key(); - banners.push(BannerState::new(key, new_text.to_string(), message_type)); + banners.push(BannerState::new(key, new_text, message_type)); if banners.len() > MAX_BANNERS { - banners.remove(0); + let evicted = banners.remove(0); + warn!( + "Banner evicted (capacity {}): {:?}", + MAX_BANNERS, evicted.message_type, + ); } } set_banners(ctx, banners); @@ -355,12 +407,21 @@ impl MessageBanner { } /// Clears the specific global banner message matching `text`. - pub fn clear_global_message(ctx: &egui::Context, text: &str) { + pub fn clear_global_message(ctx: &egui::Context, text: impl fmt::Display) { + let text = text.to_string(); let mut banners = get_banners(ctx); banners.retain(|b| b.text != text); set_banners(ctx, banners); } + /// Clears all global banner messages. + /// + /// Use when the context changes significantly (e.g., network switch) and + /// stale messages from the previous context should not persist. + pub fn clear_all_global(ctx: &egui::Context) { + set_banners(ctx, vec![]); + } + /// Returns whether any global banner messages exist. #[allow(dead_code)] pub fn has_global(ctx: &egui::Context) -> bool { @@ -374,6 +435,8 @@ impl MessageBanner { if banners.is_empty() { return; } + // Always write back: process_banner() mutates state (auto-dismiss timers, + // expanded flags) even when no banners are removed. banners.retain_mut(|b| process_banner(ui, b) == BannerStatus::Visible); set_banners(ui.ctx(), banners); } @@ -648,3 +711,68 @@ fn icon_for_type(message_type: MessageType) -> &'static str { MessageType::Info => "\u{1F4AC}", // speech balloon (💬) } } + +// --------------------------------------------------------------------------- +// Extension traits for ergonomic banner display on Result and Option. +// --------------------------------------------------------------------------- + +/// Extension for `Result` — show an error banner on `Err`, pass through unchanged. +/// +/// ```ignore +/// let wallet = get_selected_wallet(&identity, None, key) +/// .or_show_error(app_context.egui_ctx()) +/// .unwrap_or(None); +/// ``` +pub trait ResultBannerExt { + /// If `Err`, displays a global error banner with the error's `Display` text. + /// Returns `self` unchanged — this is a side-effect-only method. + fn or_show_error(self, ctx: &egui::Context) -> Self; +} + +impl ResultBannerExt for Result { + fn or_show_error(self, ctx: &egui::Context) -> Self { + if let Err(ref e) = self { + MessageBanner::set_global(ctx, e, MessageType::Error); + } + self + } +} + +/// Extension for `Option` — show an error banner on `None`, pass through unchanged. +/// +/// ```ignore +/// let identity = identities.first().cloned() +/// .or_show_error(ctx, "No identities loaded"); +/// ``` +pub trait OptionBannerShowExt { + /// If `None`, displays a global error banner with the given message. + /// Returns `self` unchanged — this is a side-effect-only method. + fn or_show_error(self, ctx: &egui::Context, msg: impl fmt::Display) -> Self; +} + +impl OptionBannerShowExt for Option { + fn or_show_error(self, ctx: &egui::Context, msg: impl fmt::Display) -> Self { + if self.is_none() { + MessageBanner::set_global(ctx, msg, MessageType::Error); + } + self + } +} + +/// Extension for `Option` — banner cleanup. +/// +/// ```ignore +/// self.refresh_banner.take_and_clear(); +/// ``` +pub trait OptionBannerExt { + /// Takes the handle (leaving `None`) and clears the associated banner. + fn take_and_clear(&mut self); +} + +impl OptionBannerExt for Option { + fn take_and_clear(&mut self) { + if let Some(h) = self.take() { + h.clear(); + } + } +} diff --git a/src/ui/components/mod.rs b/src/ui/components/mod.rs index 1c71238c4..95dc26d8c 100644 --- a/src/ui/components/mod.rs +++ b/src/ui/components/mod.rs @@ -19,4 +19,7 @@ pub mod wallet_unlock_popup; // Re-export the main traits for easy access pub use component_trait::{Component, ComponentResponse}; -pub use message_banner::{BannerHandle, BannerStatus, MessageBanner, MessageBannerResponse}; +pub use message_banner::{ + BannerHandle, BannerStatus, MessageBanner, MessageBannerResponse, OptionBannerExt, + OptionBannerShowExt, ResultBannerExt, +}; diff --git a/src/ui/components/wallet_unlock.rs b/src/ui/components/wallet_unlock.rs index 67b695035..43aa7f5ad 100644 --- a/src/ui/components/wallet_unlock.rs +++ b/src/ui/components/wallet_unlock.rs @@ -1,8 +1,10 @@ use crate::context::AppContext; use crate::model::wallet::Wallet; +use crate::ui::MessageType; +use crate::ui::components::MessageBanner; use crate::ui::components::styled::StyledCheckbox; use crate::ui::theme::DashColors; -use egui::{Frame, Margin, RichText, Ui}; +use egui::Ui; use std::sync::{Arc, RwLock}; use zeroize::Zeroize; @@ -15,9 +17,6 @@ pub trait ScreenWithWalletUnlock { fn wallet_password_mut(&mut self) -> &mut String; fn show_password(&self) -> bool; fn show_password_mut(&mut self) -> &mut bool; - fn set_error_message(&mut self, error_message: Option); - - fn error_message(&self) -> Option<&String>; fn app_context(&self) -> Arc; @@ -26,7 +25,11 @@ pub trait ScreenWithWalletUnlock { let mut wallet = wallet_guard.write().unwrap(); if !wallet.uses_password { if let Err(e) = wallet.wallet_seed.open_no_password() { - self.set_error_message(Some(e)); + MessageBanner::set_global( + self.app_context().egui_ctx(), + &e, + MessageType::Error, + ); } false } else { @@ -66,9 +69,8 @@ pub trait ScreenWithWalletUnlock { // Capture necessary values before the closure let show_password = self.show_password(); - let mut local_show_password = show_password; // Local copy of show_password - let mut local_error_message = self.error_message().cloned(); // Local variable for error message - let wallet_password_mut = self.wallet_password_mut(); // Mutable reference to the password + let mut local_show_password = show_password; + let wallet_password_mut = self.wallet_password_mut(); let mut attempt_unlock = false; @@ -107,16 +109,16 @@ pub trait ScreenWithWalletUnlock { match unlock_result { Ok(_) => { - local_error_message = None; unlocked_wallet = Some(wallet_guard.clone()); } Err(_) => { - if let Some(hint) = wallet.password_hint() { - local_error_message = - Some(format!("Incorrect Password, password hint is {}", hint)); + let error_msg = if let Some(hint) = wallet.password_hint() { + format!("Incorrect Password, password hint is {}", hint) } else { - local_error_message = Some("Incorrect Password".to_string()); - } + "Incorrect Password".to_string() + }; + MessageBanner::set_global(ui.ctx(), &error_msg, MessageType::Error) + .with_auto_dismiss(std::time::Duration::from_secs(10)); } } // Clear the password field after submission @@ -125,32 +127,7 @@ pub trait ScreenWithWalletUnlock { // Update `show_password` after the closure *self.show_password_mut() = local_show_password; - - // Update the error message - self.set_error_message(local_error_message); - - // Display error message if the password was incorrect - if let Some(error_message) = self.error_message().cloned() { - ui.add_space(5.0); - let error_color = DashColors::error_color(ui.ctx().style().visuals.dark_mode); - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label( - RichText::new(format!("Error: {}", error_message)) - .color(error_color), - ); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.set_error_message(None); - } - }); - }); - } + // Error display is handled by the global MessageBanner } } diff --git a/src/ui/contracts_documents/add_contracts_screen.rs b/src/ui/contracts_documents/add_contracts_screen.rs index 449d184b6..03fb8ca93 100644 --- a/src/ui/contracts_documents/add_contracts_screen.rs +++ b/src/ui/contracts_documents/add_contracts_screen.rs @@ -3,6 +3,7 @@ use crate::backend_task::BackendTask; use crate::backend_task::contract::ContractTask; use crate::context::AppContext; use crate::ui::components::left_panel::add_left_panel; +use crate::ui::components::message_banner::{BannerHandle, MessageBanner, OptionBannerExt}; use crate::ui::components::styled::island_central_panel; use crate::ui::components::top_panel::add_top_panel; use crate::ui::theme::DashColors; @@ -10,19 +11,17 @@ use crate::ui::{BackendTaskSuccessResult, MessageType, ScreenLike}; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::identifier::Identifier; use dash_sdk::dpp::platform_value::string_encoding::Encoding; -use dash_sdk::dpp::prelude::TimestampMillis; -use eframe::egui::{self, Color32, Context, Frame, Margin, RichText, Ui}; +use eframe::egui::{self, Color32, Context, RichText, Ui}; use std::sync::Arc; -use std::time::{SystemTime, UNIX_EPOCH}; const MAX_CONTRACTS: usize = 10; #[derive(PartialEq)] enum AddContractsStatus { NotStarted, - WaitingForResult(TimestampMillis), + WaitingForResult, Complete(Vec), - ErrorMessage(String), + Error, } pub struct AddContractsScreen { @@ -32,6 +31,7 @@ pub struct AddContractsScreen { maybe_found_contracts: Vec, alias_inputs: Option>, last_alias_result: Option<(usize, Result)>, + add_banner: Option, } impl AddContractsScreen { @@ -43,6 +43,7 @@ impl AddContractsScreen { maybe_found_contracts: vec![], alias_inputs: None, last_alias_result: None, + add_banner: None, } } @@ -79,18 +80,22 @@ impl AddContractsScreen { fn add_contracts_clicked(&mut self) -> AppAction { match self.parse_identifiers() { Ok(identifiers) => { - self.add_contracts_status = AddContractsStatus::WaitingForResult( - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(), + self.add_banner.take_and_clear(); + let handle = MessageBanner::set_global( + self.app_context.egui_ctx(), + "Adding contract...", + MessageType::Info, ); + handle.with_elapsed(); + self.add_banner = Some(handle); + self.add_contracts_status = AddContractsStatus::WaitingForResult; AppAction::BackendTask(BackendTask::ContractTask(Box::new( ContractTask::FetchContracts(identifiers), ))) } Err(e) => { - self.add_contracts_status = AddContractsStatus::ErrorMessage(e); + self.add_contracts_status = AddContractsStatus::Error; + MessageBanner::set_global(self.app_context.egui_ctx(), &e, MessageType::Error); AppAction::None } } @@ -269,10 +274,12 @@ impl AddContractsScreen { } impl ScreenLike for AddContractsScreen { - fn display_message(&mut self, message: &str, message_type: MessageType) { + fn display_message(&mut self, _message: &str, message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. match message_type { MessageType::Error | MessageType::Warning => { - self.add_contracts_status = AddContractsStatus::ErrorMessage(message.to_string()); + self.add_banner.take_and_clear(); + self.add_contracts_status = AddContractsStatus::Error; } MessageType::Success | MessageType::Info => {} } @@ -281,6 +288,7 @@ impl ScreenLike for AddContractsScreen { fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { match backend_task_success_result { BackendTaskSuccessResult::FetchedContracts(maybe_found_contracts) => { + self.add_banner.take_and_clear(); let maybe_contracts: Vec<_> = self .contract_ids_input .iter() @@ -324,29 +332,7 @@ impl ScreenLike for AddContractsScreen { ui.add_space(10.0); match &self.add_contracts_status { - AddContractsStatus::NotStarted | AddContractsStatus::ErrorMessage(_) => { - if let AddContractsStatus::ErrorMessage(msg) = &self.add_contracts_status { - let error_color = DashColors::ERROR; - let msg = msg.clone(); - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label( - RichText::new(format!("Error: {}", msg)).color(error_color), - ); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.add_contracts_status = AddContractsStatus::NotStarted; - } - }); - }); - ui.add_space(10.0); - } - + AddContractsStatus::NotStarted | AddContractsStatus::Error => { // Show input fields self.show_input_fields(ui); @@ -361,35 +347,8 @@ impl ScreenLike for AddContractsScreen { return self.add_contracts_clicked(); } } - AddContractsStatus::WaitingForResult(start_time) => { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let elapsed_seconds = now - start_time; - - let display_time = if elapsed_seconds < 60 { - format!( - "{} second{}", - elapsed_seconds, - if elapsed_seconds == 1 { "" } else { "s" } - ) - } else { - let minutes = elapsed_seconds / 60; - let seconds = elapsed_seconds % 60; - format!( - "{} minute{} and {} second{}", - minutes, - if minutes == 1 { "" } else { "s" }, - seconds, - if seconds == 1 { "" } else { "s" } - ) - }; - - ui.label(format!( - "Fetching contracts... Time taken so far: {}", - display_time - )); + AddContractsStatus::WaitingForResult => { + // Elapsed time is shown in the global banner } AddContractsStatus::Complete(_) => { return self.show_success_screen(ui); diff --git a/src/ui/contracts_documents/contracts_documents_screen.rs b/src/ui/contracts_documents/contracts_documents_screen.rs index 9aa6d3738..3382368df 100644 --- a/src/ui/contracts_documents/contracts_documents_screen.rs +++ b/src/ui/contracts_documents/contracts_documents_screen.rs @@ -10,23 +10,21 @@ use crate::ui::components::contract_chooser_panel::{ ContractChooserState, add_contract_chooser_panel, }; use crate::ui::components::left_panel::add_left_panel; +use crate::ui::components::message_banner::{BannerHandle, MessageBanner, OptionBannerExt}; use crate::ui::components::top_panel::add_top_panel; use crate::ui::theme::{DashColors, Shadow, Shape}; use crate::ui::{BackendTaskSuccessResult, MessageType, RootScreenType, ScreenLike, ScreenType}; use crate::utils::parsers::{DocumentQueryTextInputParser, TextInputParser}; -use chrono::{DateTime, Utc}; use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dash_sdk::dpp::data_contract::document_type::{DocumentType, Index}; use dash_sdk::dpp::platform_value::string_encoding::Encoding; -use dash_sdk::dpp::prelude::TimestampMillis; use dash_sdk::platform::proto::get_documents_request::get_documents_request_v0::Start; use dash_sdk::platform::{Document, DocumentQuery, Identifier}; use egui::{CentralPanel, Color32, Context, Frame, Margin, ScrollArea, Stroke, Ui}; use std::collections::HashMap; use std::sync::Arc; -use std::time::{SystemTime, UNIX_EPOCH}; /// A list of Dash-specific fields that do not appear in the /// normal document_type properties. @@ -47,7 +45,6 @@ pub const DOCUMENT_PRIVATE_FIELDS: &[&str] = &[ pub struct DocumentQueryScreen { pub app_context: Arc, - error_message: Option<(String, MessageType, DateTime)>, contract_search_term: String, document_search_term: String, document_query: String, @@ -70,14 +67,15 @@ pub struct DocumentQueryScreen { previous_cursors: Vec, // Contract chooser state contract_chooser_state: ContractChooserState, + query_banner: Option, } #[derive(PartialEq, Eq, Clone)] pub enum DocumentQueryStatus { NotStarted, - WaitingForResult(TimestampMillis), + WaitingForResult, Complete, - ErrorMessage(String), + Error, } #[derive(PartialEq, Eq, Clone)] @@ -112,7 +110,6 @@ impl DocumentQueryScreen { Self { app_context: app_context.clone(), - error_message: None, contract_search_term: String::new(), document_search_term: String::new(), document_query: format!("SELECT * FROM {}", selected_document_type.name()), @@ -134,22 +131,7 @@ impl DocumentQueryScreen { has_next_page: false, previous_cursors: Vec::new(), contract_chooser_state: ContractChooserState::default(), - } - } - - fn dismiss_error(&mut self) { - self.error_message = None; - } - - fn check_error_expiration(&mut self) { - if let Some((_, _, timestamp)) = &self.error_message { - let now = Utc::now(); - let elapsed = now.signed_duration_since(*timestamp); - - // Automatically dismiss the error message after 10 seconds - if elapsed.num_seconds() > 10 { - self.dismiss_error(); - } + query_banner: None, } } @@ -208,12 +190,16 @@ impl DocumentQueryScreen { DocumentQueryTextInputParser::new(self.selected_data_contract.contract.clone()); match parser.parse_input(&self.document_query) { Ok(parsed_query) => { - // Set the status to waiting and capture the current time - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - self.document_query_status = DocumentQueryStatus::WaitingForResult(now); + // Set the status to waiting + self.query_banner.take_and_clear(); + let handle = MessageBanner::set_global( + ui.ctx(), + "Querying documents...", + MessageType::Info, + ); + handle.with_elapsed(); + self.query_banner = Some(handle); + self.document_query_status = DocumentQueryStatus::WaitingForResult; self.current_page = 1; // Reset to first page self.next_cursors = vec![]; // Reset cursor self.previous_cursors.clear(); // Clear previous cursors @@ -222,15 +208,13 @@ impl DocumentQueryScreen { ))); } Err(e) => { - self.document_query_status = DocumentQueryStatus::ErrorMessage(format!( - "Failed to parse query properly: {}", - e - )); - self.error_message = Some(( + self.query_banner.take_and_clear(); + self.document_query_status = DocumentQueryStatus::Error; + MessageBanner::set_global( + ui.ctx(), format!("Failed to parse query properly: {}", e), MessageType::Error, - Utc::now(), - )); + ); } } } @@ -241,7 +225,6 @@ impl DocumentQueryScreen { fn show_output(&mut self, ui: &mut Ui) -> AppAction { let mut action = AppAction::None; - let dark_mode = ui.ctx().style().visuals.dark_mode; ui.separator(); ui.add_space(10.0); @@ -343,19 +326,8 @@ impl DocumentQueryScreen { ui.set_width(ui.available_width()); match self.document_query_status { - DocumentQueryStatus::WaitingForResult(start_time) => { - let time_elapsed = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs() - - start_time; - ui.horizontal(|ui| { - ui.label(format!( - "Fetching documents... Time taken so far: {} seconds", - time_elapsed - )); - ui.add(egui::widgets::Spinner::default().color(DashColors::DASH_BLUE)); - }); + DocumentQueryStatus::WaitingForResult => { + // Elapsed time is shown in the global banner } DocumentQueryStatus::Complete => match self.document_display_mode { DocumentDisplayMode::Json => { @@ -366,10 +338,8 @@ impl DocumentQueryScreen { } }, - DocumentQueryStatus::ErrorMessage(ref message) => { - self.error_message = - Some((message.to_string(), MessageType::Error, Utc::now())); - ui.colored_label(DashColors::error_color(dark_mode), message); + DocumentQueryStatus::Error => { + // Error message is displayed globally via MessageBanner } _ => { // Nothing @@ -387,12 +357,15 @@ impl DocumentQueryScreen { if self.current_page > 1 && ui.button("Previous Page").clicked() { // Handle Previous Page if let Some(prev_cursor) = self.get_previous_cursor() { - self.document_query_status = DocumentQueryStatus::WaitingForResult( - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(), + self.query_banner.take_and_clear(); + let handle = MessageBanner::set_global( + ui.ctx(), + "Querying documents...", + MessageType::Info, ); + handle.with_elapsed(); + self.query_banner = Some(handle); + self.document_query_status = DocumentQueryStatus::WaitingForResult; self.current_page -= 1; self.next_cursors.pop(); let parsed_query = self.build_document_query_with_cursor(&prev_cursor); @@ -400,12 +373,15 @@ impl DocumentQueryScreen { DocumentTask::FetchDocumentsPage(parsed_query), ))); } else { - self.document_query_status = DocumentQueryStatus::WaitingForResult( - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(), + self.query_banner.take_and_clear(); + let handle = MessageBanner::set_global( + ui.ctx(), + "Querying documents...", + MessageType::Info, ); + handle.with_elapsed(); + self.query_banner = Some(handle); + self.document_query_status = DocumentQueryStatus::WaitingForResult; self.current_page = 1; let next_cursor = self.get_next_cursor().unwrap_or(Start::StartAfter(vec![])); // Doesn't matter what the value is @@ -421,12 +397,15 @@ impl DocumentQueryScreen { if self.has_next_page && ui.button("Next Page").clicked() { // Handle Next Page if let Some(next_cursor) = &self.get_next_cursor() { - self.document_query_status = DocumentQueryStatus::WaitingForResult( - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(), + self.query_banner.take_and_clear(); + let handle = MessageBanner::set_global( + ui.ctx(), + "Querying documents...", + MessageType::Info, ); + handle.with_elapsed(); + self.query_banner = Some(handle); + self.document_query_status = DocumentQueryStatus::WaitingForResult; if self.current_page > 1 { self.previous_cursors.push( self.next_cursors @@ -539,7 +518,6 @@ impl ScreenLike for DocumentQueryScreen { fn refresh(&mut self) { // Reset the screen state - self.error_message = None; self.contract_search_term.clear(); self.document_search_term.clear(); self.document_query.clear(); @@ -563,16 +541,19 @@ impl ScreenLike for DocumentQueryScreen { } fn display_message(&mut self, message: &str, message_type: MessageType) { - // Only display the error message resulting from FetchDocuments backend task - if message.contains("Error fetching documents") { - self.document_query_status = DocumentQueryStatus::ErrorMessage(message.to_string()); - self.error_message = Some((message.to_string(), message_type, Utc::now())); + // Banner display is handled globally by AppState; this is only for side-effects. + if message.contains("Error fetching documents") + && matches!(message_type, MessageType::Error | MessageType::Warning) + { + self.query_banner.take_and_clear(); + self.document_query_status = DocumentQueryStatus::Error; } } fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { match backend_task_success_result { BackendTaskSuccessResult::Documents(documents) => { + self.query_banner.take_and_clear(); self.matching_documents = documents .iter() .filter_map(|(_, doc)| doc.clone()) @@ -580,6 +561,7 @@ impl ScreenLike for DocumentQueryScreen { self.document_query_status = DocumentQueryStatus::Complete; } BackendTaskSuccessResult::PageDocuments(page_docs, next_cursor) => { + self.query_banner.take_and_clear(); self.matching_documents = page_docs .iter() .filter_map(|(_, doc)| doc.clone()) @@ -597,7 +579,6 @@ impl ScreenLike for DocumentQueryScreen { } fn ui(&mut self, ctx: &Context) -> AppAction { - self.check_error_expiration(); let load_contract_button = ( "Load Contracts", DesiredAppAction::AddScreenType(Box::new(ScreenType::AddContracts)), @@ -727,6 +708,7 @@ impl ScreenLike for DocumentQueryScreen { .corner_radius(egui::CornerRadius::same(Shape::RADIUS_LG)) .shadow(Shadow::elevated()) .show(ui, |ui| { + MessageBanner::show_global(ui); let mut inner_action = AppAction::None; // Use a vertical layout that allocates space properly diff --git a/src/ui/contracts_documents/document_action_screen.rs b/src/ui/contracts_documents/document_action_screen.rs index 4d0301d5f..16b85598f 100644 --- a/src/ui/contracts_documents/document_action_screen.rs +++ b/src/ui/contracts_documents/document_action_screen.rs @@ -7,6 +7,7 @@ use crate::model::fee_estimation::format_credits_as_dash; use crate::model::qualified_contract::QualifiedContract; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; +use crate::ui::MessageType; use crate::ui::ScreenLike; use crate::ui::components::identity_selector::IdentitySelector; use crate::ui::components::left_panel::add_left_panel; @@ -15,6 +16,7 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; +use crate::ui::components::{MessageBanner, ResultBannerExt}; use crate::ui::helpers::{ TransactionType, add_contract_doc_type_chooser_with_filtering, add_key_chooser_with_doc_type, show_success_screen_with_info, @@ -90,7 +92,6 @@ pub struct DocumentActionScreen { pub action_type: DocumentActionType, // Common fields - pub backend_message: Option, pub selected_identity: Option, selected_identity_string: String, pub selected_key: Option, @@ -159,7 +160,6 @@ impl DocumentActionScreen { Self { app_context, action_type, - backend_message: None, selected_identity, selected_identity_string, selected_key: None, @@ -184,7 +184,6 @@ impl DocumentActionScreen { } fn reset_screen(&mut self) { - self.backend_message = None; self.selected_identity = None; self.selected_identity_string = String::new(); self.selected_key = None; @@ -267,12 +266,9 @@ impl DocumentActionScreen { .cloned(); // Update wallet - self.wallet = get_selected_wallet( - identity, - Some(&self.app_context), - None, - &mut self.backend_message, - ); + self.wallet = get_selected_wallet(identity, Some(&self.app_context), None) + .or_show_error(self.app_context.egui_ctx()) + .unwrap_or(None); } else { self.selected_key = None; self.wallet = None; @@ -453,9 +449,7 @@ impl DocumentActionScreen { } } - if let Some(backend_message) = &self.backend_message - && backend_message.contains("No owned documents found") - { + if self.broadcast_status == BroadcastStatus::Fetched && self.fetched_documents.is_empty() { ui.add_space(10.0); ui.label("No owned documents found."); } @@ -515,7 +509,11 @@ impl DocumentActionScreen { ))); } } else { - self.backend_message = Some("Invalid Document ID format".to_string()); + MessageBanner::set_global( + ui.ctx(), + "Invalid Document ID format", + MessageType::Error, + ); } } @@ -580,7 +578,11 @@ impl DocumentActionScreen { ))); } } else { - self.backend_message = Some("Invalid Document ID format".to_string()); + MessageBanner::set_global( + ui.ctx(), + "Invalid Document ID format", + MessageType::Error, + ); } } }); @@ -919,7 +921,6 @@ impl DocumentActionScreen { .min_size(egui::vec2(100.0, 30.0)); if ui.add(button).clicked() && self.can_broadcast() { - self.backend_message = None; let task = self.create_document_action(); if task != BackendTask::None { self.broadcast_status = BroadcastStatus::Broadcasting( @@ -999,7 +1000,11 @@ impl DocumentActionScreen { ))) } Err(e) => { - self.backend_message = Some(format!("Failed to build document: {}", e)); + MessageBanner::set_global( + self.app_context.egui_ctx(), + format!("Failed to build document: {}", e), + MessageType::Error, + ); BackendTask::None } } @@ -1094,7 +1099,11 @@ impl DocumentActionScreen { ))) } Err(e) => { - self.backend_message = Some(format!("Failed to build updated document: {}", e)); + MessageBanner::set_global( + self.app_context.egui_ctx(), + format!("Failed to build updated document: {}", e), + MessageType::Error, + ); BackendTask::None } } @@ -1629,9 +1638,14 @@ impl ScreenLike for DocumentActionScreen { // Backend messages are handled via display_message } - fn display_message(&mut self, message: &str, _message_type: crate::ui::MessageType) { - self.backend_message = Some(message.to_string()); - self.broadcast_status = BroadcastStatus::NotBroadcasted; + fn display_message(&mut self, _message: &str, message_type: crate::ui::MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. + if matches!( + message_type, + crate::ui::MessageType::Error | crate::ui::MessageType::Warning + ) { + self.broadcast_status = BroadcastStatus::NotBroadcasted; + } } fn display_task_result(&mut self, result: crate::ui::BackendTaskSuccessResult) { @@ -1703,28 +1717,33 @@ impl ScreenLike for DocumentActionScreen { self.fetched_price = Some(price); } Ok(None) => { - self.backend_message = - Some("Document has no price set".to_string()); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Document has no price set", + MessageType::Error, + ); self.fetched_price = None; } Err(_) => { - self.backend_message = - Some("Failed to get document price".to_string()); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Failed to get document price", + MessageType::Error, + ); self.fetched_price = None; } } } else { - self.backend_message = Some("No document found".to_string()); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "No document found", + MessageType::Error, + ); self.fetched_price = None; } } DocumentActionType::Delete => { // For delete, store the fetched documents - if documents.is_empty() { - self.backend_message = Some("No owned documents found".to_string()); - } else { - self.backend_message = None; - } self.fetched_documents = documents; } _ => {} @@ -1765,16 +1784,14 @@ impl DocumentActionScreen { // Wallet unlock if let Some(selected_identity) = &self.selected_identity { - self.wallet = get_selected_wallet( - selected_identity, - Some(&self.app_context), - None, - &mut self.backend_message, - ); + self.wallet = + get_selected_wallet(selected_identity, Some(&self.app_context), None) + .or_show_error(ui.ctx()) + .unwrap_or(None); } if let Some(wallet) = &self.wallet { if let Err(e) = try_open_wallet_no_password(wallet) { - self.backend_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } if wallet_needs_unlock(wallet) { ui.add_space(10.0); @@ -1796,26 +1813,6 @@ impl DocumentActionScreen { _ => self.render_action_specific_inputs(ui), }; - if let Some(ref msg) = self.backend_message { - ui.add_space(10.0); - let error_color = DashColors::error_color(ui.visuals().dark_mode); - let msg = msg.clone(); - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label(RichText::new(&msg).color(error_color)); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.backend_message = None; - } - }); - }); - } - action }) .inner diff --git a/src/ui/contracts_documents/group_actions_screen.rs b/src/ui/contracts_documents/group_actions_screen.rs index 3ae003e61..ed877deb6 100644 --- a/src/ui/contracts_documents/group_actions_screen.rs +++ b/src/ui/contracts_documents/group_actions_screen.rs @@ -17,6 +17,7 @@ use crate::model::qualified_contract::QualifiedContract; use crate::model::qualified_identity::QualifiedIdentity; use crate::ui::components::identity_selector::IdentitySelector; use crate::ui::components::left_panel::add_left_panel; +use crate::ui::components::message_banner::{BannerHandle, MessageBanner, OptionBannerExt}; use crate::ui::components::styled::island_central_panel; use crate::ui::components::top_panel::add_top_panel; use crate::ui::helpers::add_contract_chooser_pre_filtered; @@ -44,24 +45,22 @@ use dash_sdk::dpp::group::action_event::GroupActionEvent; use dash_sdk::dpp::group::group_action::{GroupAction, GroupActionAccessors}; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::platform_value::string_encoding::Encoding; -use dash_sdk::dpp::prelude::TimestampMillis; use dash_sdk::dpp::tokens::emergency_action::TokenEmergencyAction; use dash_sdk::dpp::tokens::token_event::TokenEvent; use dash_sdk::platform::Identifier; use dash_sdk::query_types::IndexMap; -use eframe::egui::{self, Color32, Context, Frame, Margin, RichText}; +use eframe::egui::{self, Color32, Context, RichText}; use egui::{ScrollArea, TextStyle}; use egui_extras::{Column, TableBuilder}; use std::collections::BTreeMap; use std::sync::Arc; -use std::time::{SystemTime, UNIX_EPOCH}; // Status of the fetch group actions task enum FetchGroupActionsStatus { NotStarted, - WaitingForResult(TimestampMillis), + WaitingForResult, Complete(IndexMap), - ErrorMessage(String), + Error, } /// The screen @@ -84,6 +83,7 @@ pub struct GroupActionsScreen { // Backend task status fetch_group_actions_status: FetchGroupActionsStatus, + fetch_banner: Option, // App Context pub app_context: Arc, @@ -149,6 +149,7 @@ impl GroupActionsScreen { // Backend task status fetch_group_actions_status: FetchGroupActionsStatus::NotStarted, + fetch_banner: None, // App Context app_context: app_context.clone(), @@ -259,9 +260,12 @@ impl GroupActionsScreen { Some(identity_token_balance) => identity_token_balance, None => { self.fetch_group_actions_status = - FetchGroupActionsStatus::ErrorMessage( - "No identity token balance found".to_string(), - ); + FetchGroupActionsStatus::Error; + MessageBanner::set_global( + ui.ctx(), + "No identity token balance found", + MessageType::Error, + ); return; } }; @@ -269,9 +273,12 @@ impl GroupActionsScreen { Ok(identity_token_info) => identity_token_info, Err(e) => { self.fetch_group_actions_status = - FetchGroupActionsStatus::ErrorMessage( - format!("Failed to get identity token info: {}", e), - ); + FetchGroupActionsStatus::Error; + MessageBanner::set_global( + ui.ctx(), + format!("Failed to get identity token info: {}", e), + MessageType::Error, + ); return; } }; @@ -357,7 +364,6 @@ impl GroupActionsScreen { TokenEvent::Mint(amount, _identifier, note_opt) => { let mut mint_screen = MintTokensScreen::new(identity_token_info, &self.app_context); mint_screen.group_action_id = Some(action_id); - // Convert amount to Amount struct using the token configuration mint_screen.amount = Some(Amount::from_token( &mint_screen.identity_token_info, *amount, @@ -451,11 +457,12 @@ impl ScreenLike for GroupActionsScreen { self.fetch_group_actions_status = FetchGroupActionsStatus::NotStarted; } - fn display_message(&mut self, message: &str, message_type: MessageType) { + fn display_message(&mut self, _message: &str, message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. match message_type { MessageType::Error | MessageType::Warning => { - self.fetch_group_actions_status = - FetchGroupActionsStatus::ErrorMessage(message.to_string()); + self.fetch_banner.take_and_clear(); + self.fetch_group_actions_status = FetchGroupActionsStatus::Error; } MessageType::Success | MessageType::Info => {} } @@ -465,6 +472,7 @@ impl ScreenLike for GroupActionsScreen { if let BackendTaskSuccessResult::ActiveGroupActions(actions_map) = backend_task_success_result { + self.fetch_banner.take_and_clear(); self.fetch_group_actions_status = FetchGroupActionsStatus::Complete(actions_map.clone()); } @@ -556,62 +564,26 @@ impl ScreenLike for GroupActionsScreen { .corner_radius(3.0); if ui.add(button).clicked() { - self.fetch_group_actions_status = FetchGroupActionsStatus::WaitingForResult( - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(), + self.fetch_banner.take_and_clear(); + let handle = MessageBanner::set_global( + ui.ctx(), + "Fetching group actions...", + MessageType::Info, ); + handle.with_elapsed(); + self.fetch_banner = Some(handle); + self.fetch_group_actions_status = FetchGroupActionsStatus::WaitingForResult; fetch_clicked = true; } } match &self.fetch_group_actions_status { - FetchGroupActionsStatus::ErrorMessage(msg) => { - ui.add_space(10.0); - let error_color = DashColors::ERROR; - let msg = msg.clone(); - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label( - RichText::new(format!("Error: {}", msg)).color(error_color), - ); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.fetch_group_actions_status = - FetchGroupActionsStatus::NotStarted; - } - }); - }); + FetchGroupActionsStatus::Error => { + // Error message is displayed globally via MessageBanner } - FetchGroupActionsStatus::WaitingForResult(start_time) => { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let elapsed = now - start_time; - let status = if elapsed < 60 { - format!("{} second{}", elapsed, if elapsed == 1 { "" } else { "s" }) - } else { - format!( - "{} minute{} and {} second{}", - elapsed / 60, - if elapsed / 60 == 1 { "" } else { "s" }, - elapsed % 60, - if elapsed % 60 == 1 { "" } else { "s" } - ) - }; - ui.add_space(10.0); - ui.label(format!( - "Fetching group actions… Time taken so far: {}", - status - )); + FetchGroupActionsStatus::WaitingForResult => { + // Elapsed time is shown in the global banner } _ => {} diff --git a/src/ui/contracts_documents/register_contract_screen.rs b/src/ui/contracts_documents/register_contract_screen.rs index 0ad014a53..e10fb553d 100644 --- a/src/ui/contracts_documents/register_contract_screen.rs +++ b/src/ui/contracts_documents/register_contract_screen.rs @@ -13,6 +13,7 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; +use crate::ui::components::{MessageBanner, ResultBannerExt}; use crate::ui::helpers::{TransactionType, add_key_chooser}; use crate::ui::identities::get_selected_wallet; use crate::ui::theme::DashColors; @@ -35,7 +36,7 @@ enum BroadcastStatus { ValidContract(Box), Broadcasting(u64), ProofError(u64), - BroadcastError(String), + BroadcastError, Done, } @@ -53,7 +54,6 @@ pub struct RegisterDataContractScreen { pub selected_wallet: Option>>, wallet_unlock_popup: WalletUnlockPopup, - error_message: Option, completed_fee_result: Option, } @@ -64,9 +64,10 @@ impl RegisterDataContractScreen { let selected_qualified_identity = qualified_identities.first().cloned(); - let mut error_message: Option = None; let selected_wallet = if let Some(ref identity) = selected_qualified_identity { - get_selected_wallet(identity, Some(app_context), None, &mut error_message) + get_selected_wallet(identity, Some(app_context), None) + .or_show_error(app_context.egui_ctx()) + .unwrap_or(None) } else { None }; @@ -108,7 +109,6 @@ impl RegisterDataContractScreen { selected_wallet, wallet_unlock_popup: WalletUnlockPopup::new(), - error_message: None, completed_fee_result: None, } } @@ -171,9 +171,9 @@ impl RegisterDataContractScreen { /// Renders an error message at the top of the screen with a styled bubble fn render_error_bubble(&mut self, ui: &mut egui::Ui) { + // Only show local parsing errors; broadcast errors are handled by global MessageBanner let error_msg = match &self.broadcast_status { BroadcastStatus::ParsingError(err) => Some(format!("Parsing error: {err}")), - BroadcastStatus::BroadcastError(msg) => Some(format!("Broadcast error: {msg}")), _ => None, }; @@ -207,8 +207,8 @@ impl RegisterDataContractScreen { BroadcastStatus::Idle => { ui.label("No contract parsed yet or empty input."); } - BroadcastStatus::ParsingError(_) | BroadcastStatus::BroadcastError(_) => { - // Errors are now shown at the top via render_error_bubble + BroadcastStatus::ParsingError(_) | BroadcastStatus::BroadcastError => { + // Parsing errors shown via render_error_bubble; broadcast errors via global banner } BroadcastStatus::ValidContract(contract) => { // Display estimated fee using SDK's registration_cost method @@ -341,12 +341,12 @@ impl RegisterDataContractScreen { impl ScreenLike for RegisterDataContractScreen { fn display_message(&mut self, message: &str, message_type: MessageType) { - if message_type == MessageType::Error { + // Banner display is handled globally by AppState; this is only for side-effects. + if matches!(message_type, MessageType::Error | MessageType::Warning) { if message.contains("proof error logged, contract inserted into the database") { - self.error_message = Some(message.to_string()); self.broadcast_status = BroadcastStatus::Done; } else { - self.broadcast_status = BroadcastStatus::BroadcastError(message.to_string()); + self.broadcast_status = BroadcastStatus::BroadcastError; } } } @@ -480,8 +480,9 @@ impl ScreenLike for RegisterDataContractScreen { identity, Some(&self.app_context), None, - &mut self.error_message, - ); + ) + .or_show_error(ui.ctx()) + .unwrap_or(None); // Re-parse contract with new owner ID self.parse_contract(); @@ -524,7 +525,7 @@ impl ScreenLike for RegisterDataContractScreen { // Render wallet unlock if needed if let Some(wallet) = &self.selected_wallet { if let Err(e) = try_open_wallet_no_password(wallet) { - self.error_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } if wallet_needs_unlock(wallet) { ui.add_space(10.0); diff --git a/src/ui/contracts_documents/update_contract_screen.rs b/src/ui/contracts_documents/update_contract_screen.rs index f88293156..4d3edaac1 100644 --- a/src/ui/contracts_documents/update_contract_screen.rs +++ b/src/ui/contracts_documents/update_contract_screen.rs @@ -14,6 +14,7 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; +use crate::ui::components::{MessageBanner, ResultBannerExt}; use crate::ui::helpers::{TransactionType, add_key_chooser}; use crate::ui::identities::get_selected_wallet; use crate::ui::theme::DashColors; @@ -39,7 +40,6 @@ enum BroadcastStatus { FetchingNonce(u64), Broadcasting(u64), ProofError(u64), - BroadcastError(String), Done, } @@ -58,7 +58,6 @@ pub struct UpdateDataContractScreen { pub selected_wallet: Option>>, wallet_unlock_popup: WalletUnlockPopup, - error_message: Option, completed_fee_result: Option, } @@ -68,9 +67,10 @@ impl UpdateDataContractScreen { app_context.load_local_user_identities().unwrap_or_default(); let selected_qualified_identity = qualified_identities.first().cloned(); - let mut error_message: Option = None; let selected_wallet = if let Some(ref identity) = selected_qualified_identity { - get_selected_wallet(identity, Some(app_context), None, &mut error_message) + get_selected_wallet(identity, Some(app_context), None) + .or_show_error(app_context.egui_ctx()) + .unwrap_or(None) } else { None }; @@ -118,7 +118,6 @@ impl UpdateDataContractScreen { selected_wallet, wallet_unlock_popup: WalletUnlockPopup::new(), - error_message: None, completed_fee_result: None, } } @@ -185,9 +184,9 @@ impl UpdateDataContractScreen { /// Renders an error message at the top of the screen with a styled bubble fn render_error_bubble(&mut self, ui: &mut egui::Ui) { + // Only show local parsing errors; broadcast errors are handled by global MessageBanner let error_msg = match &self.broadcast_status { BroadcastStatus::ParsingError(err) => Some(format!("Parsing error: {err}")), - BroadcastStatus::BroadcastError(msg) => Some(format!("Broadcast error: {msg}")), _ => None, }; @@ -218,8 +217,8 @@ impl UpdateDataContractScreen { match &self.broadcast_status { BroadcastStatus::Idle => {} - BroadcastStatus::ParsingError(_) | BroadcastStatus::BroadcastError(_) => { - // Errors are now shown at the top via render_error_bubble + BroadcastStatus::ParsingError(_) => { + // Parsing errors shown via render_error_bubble } BroadcastStatus::ValidContract(contract) => { // Fee estimation display - contract updates charge registration fees for the new contract @@ -362,12 +361,14 @@ impl UpdateDataContractScreen { impl ScreenLike for UpdateDataContractScreen { fn display_message(&mut self, message: &str, message_type: MessageType) { - if message_type == MessageType::Error { + // Banner display is handled globally by AppState; this is only for side-effects. + if matches!(message_type, MessageType::Error | MessageType::Warning) { if message.contains("proof error logged, contract inserted into the database") { - self.error_message = Some(message.to_string()); self.broadcast_status = BroadcastStatus::Done; } else { - self.broadcast_status = BroadcastStatus::BroadcastError(message.to_string()); + // Re-parse the contract so ValidContract state is restored + // and the user can retry broadcasting. + self.parse_contract(); } } } @@ -494,12 +495,10 @@ impl ScreenLike for UpdateDataContractScreen { .cloned(); // Update wallet - self.selected_wallet = get_selected_wallet( - identity, - Some(&self.app_context), - None, - &mut self.error_message, - ); + self.selected_wallet = + get_selected_wallet(identity, Some(&self.app_context), None) + .or_show_error(ui.ctx()) + .unwrap_or(None); // Re-parse contract with new owner ID self.parse_contract(); @@ -542,7 +541,7 @@ impl ScreenLike for UpdateDataContractScreen { // Render the wallet unlock if needed if let Some(wallet) = &self.selected_wallet { if let Err(e) = try_open_wallet_no_password(wallet) { - self.error_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } if wallet_needs_unlock(wallet) { ui.add_space(10.0); diff --git a/src/ui/dashpay/add_contact_screen.rs b/src/ui/dashpay/add_contact_screen.rs index eb16e18f1..a9a3de65d 100644 --- a/src/ui/dashpay/add_contact_screen.rs +++ b/src/ui/dashpay/add_contact_screen.rs @@ -5,6 +5,7 @@ use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::context::AppContext; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; +use crate::ui::components::ResultBannerExt; use crate::ui::components::dashpay_subscreen_chooser_panel::add_dashpay_subscreen_chooser_panel; use crate::ui::components::identity_selector::IdentitySelector; use crate::ui::components::info_popup::InfoPopup; @@ -45,7 +46,6 @@ pub struct AddContactScreen { selected_key: Option, username_or_id: String, account_label: String, - message: Option<(String, MessageType)>, status: ContactRequestStatus, show_info_popup: bool, show_advanced_options: bool, @@ -62,7 +62,6 @@ impl AddContactScreen { selected_key: None, username_or_id: String::new(), account_label: String::new(), - message: None, status: ContactRequestStatus::NotStarted, show_info_popup: false, show_advanced_options: false, @@ -79,7 +78,6 @@ impl AddContactScreen { selected_key: None, username_or_id: identity_id, account_label: String::new(), - message: None, status: ContactRequestStatus::NotStarted, show_info_popup: false, show_advanced_options: false, @@ -97,8 +95,7 @@ impl AddContactScreen { let error = DashPayError::MissingField { field: "username or identity ID".to_string(), }; - self.status = ContactRequestStatus::Error(error.clone()); - self.display_message(&error.user_message(), MessageType::Error); + self.status = ContactRequestStatus::Error(error); return AppAction::None; } @@ -107,8 +104,7 @@ impl AddContactScreen { let error = DashPayError::InvalidUsername { username: self.username_or_id.clone(), }; - self.status = ContactRequestStatus::Error(error.clone()); - self.display_message(&error.user_message(), MessageType::Error); + self.status = ContactRequestStatus::Error(error); return AppAction::None; } @@ -118,8 +114,7 @@ impl AddContactScreen { length: self.account_label.len(), max: 100, }; - self.status = ContactRequestStatus::Error(error.clone()); - self.display_message(&error.user_message(), MessageType::Error); + self.status = ContactRequestStatus::Error(error); return AppAction::None; } @@ -148,8 +143,7 @@ impl AddContactScreen { field: "signing key".to_string(), } }; - self.status = ContactRequestStatus::Error(error.clone()); - self.display_message(&error.user_message(), MessageType::Error); + self.status = ContactRequestStatus::Error(error); AppAction::None } } @@ -190,7 +184,6 @@ impl ScreenLike for AddContactScreen { if !matches!(self.status, ContactRequestStatus::Success(_)) { self.status = ContactRequestStatus::NotStarted; } - self.message = None; } fn ui(&mut self, ctx: &Context) -> AppAction { @@ -235,20 +228,6 @@ impl ScreenLike for AddContactScreen { }); ui.separator(); - // Show message if any (but not if we have an error status, to avoid duplication) - if !matches!(self.status, ContactRequestStatus::Error(_)) - && let Some((message, message_type)) = &self.message - { - let color = match message_type { - MessageType::Success => egui::Color32::DARK_GREEN, - MessageType::Error => egui::Color32::DARK_RED, - MessageType::Warning => DashColors::WARNING, - MessageType::Info => egui::Color32::LIGHT_BLUE, - }; - ui.colored_label(color, message); - ui.separator(); - } - // Identity and Key selector let identities = self .app_context @@ -307,13 +286,10 @@ impl ScreenLike for AddContactScreen { // Update wallet if not already set if self.selected_wallet.is_none() { - let mut error_message = None; - self.selected_wallet = get_selected_wallet( - identity, - Some(&self.app_context), - None, - &mut error_message, - ); + self.selected_wallet = + get_selected_wallet(identity, Some(&self.app_context), None) + .or_show_error(self.app_context.egui_ctx()) + .unwrap_or(None); } } else { self.selected_key = None; @@ -520,7 +496,11 @@ impl ScreenLike for AddContactScreen { // Check wallet lock status before showing send button let wallet_locked = if let Some(wallet) = &self.selected_wallet { if let Err(e) = try_open_wallet_no_password(wallet) { - self.message = Some((e, MessageType::Error)); + crate::ui::components::MessageBanner::set_global( + ui.ctx(), + &e, + MessageType::Error, + ); } wallet_needs_unlock(wallet) } else { @@ -575,9 +555,8 @@ impl ScreenLike for AddContactScreen { { ui.add_space(10.0); if ui.button("Retry").clicked() { - // Clear both status and message before retrying + // Clear status before retrying self.status = ContactRequestStatus::NotStarted; - self.message = None; inner_action |= self.send_contact_request(); } } @@ -618,8 +597,8 @@ impl ScreenLike for AddContactScreen { } fn display_message(&mut self, message: &str, message_type: MessageType) { - self.message = Some((message.to_string(), message_type)); - if message_type == MessageType::Error { + // Banner display is handled globally by AppState; this is only for side-effects. + if matches!(message_type, MessageType::Error | MessageType::Warning) { let error = DashPayError::Internal { message: message.to_string(), }; @@ -641,7 +620,9 @@ impl ScreenLike for AddContactScreen { self.selected_key = None; } BackendTaskSuccessResult::Message(message) => { - // Handle error messages only - success is handled by DashPayContactRequestSent + // TODO(RUST-004): Replace string-based error matching with structured + // error types through the task result system. This is fragile — if + // upstream error wording changes, classification silently breaks. if message.contains("Error") || message.contains("Failed") || message.contains("does not have") @@ -673,9 +654,7 @@ impl ScreenLike for AddContactScreen { } }; - self.status = ContactRequestStatus::Error(error.clone()); - // Don't set message field to avoid duplicate error display - self.message = None; + self.status = ContactRequestStatus::Error(error); } // Ignore other messages - they're not for this screen } diff --git a/src/ui/dashpay/contact_details.rs b/src/ui/dashpay/contact_details.rs index b904101a4..68baa3d90 100644 --- a/src/ui/dashpay/contact_details.rs +++ b/src/ui/dashpay/contact_details.rs @@ -3,6 +3,7 @@ use crate::backend_task::dashpay::DashPayTask; use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::context::AppContext; use crate::model::qualified_identity::QualifiedIdentity; +use crate::ui::components::MessageBanner; use crate::ui::components::dashpay_subscreen_chooser_panel::add_dashpay_subscreen_chooser_panel; use crate::ui::components::info_popup::InfoPopup; use crate::ui::components::left_panel::add_left_panel; @@ -55,7 +56,6 @@ pub struct ContactDetailsScreen { edit_nickname: String, edit_note: String, edit_hidden: bool, - message: Option<(String, MessageType)>, loading: bool, show_info_popup: bool, needs_backend_fetch: bool, @@ -77,7 +77,6 @@ impl ContactDetailsScreen { edit_nickname: String::new(), edit_note: String::new(), edit_hidden: false, - message: None, loading: false, show_info_popup: false, needs_backend_fetch: true, @@ -257,18 +256,6 @@ impl ContactDetailsScreen { ui.separator(); - // Show message if any - if let Some((message, message_type)) = &self.message { - let color = match message_type { - MessageType::Success => DashColors::SUCCESS, - MessageType::Error => DashColors::ERROR, - MessageType::Warning => DashColors::WARNING, - MessageType::Info => DashColors::INFO, - }; - ui.colored_label(color, message); - ui.separator(); - } - // Loading indicator if self.loading { ui.horizontal(|ui| { @@ -520,21 +507,15 @@ impl ContactDetailsScreen { action } - - pub fn display_message(&mut self, message: &str, message_type: MessageType) { - self.message = Some((message.to_string(), message_type)); - } } impl ScreenLike for ContactDetailsScreen { fn refresh(&mut self) { self.load_from_database(); - self.message = None; } fn refresh_on_arrival(&mut self) { self.load_from_database(); - self.message = None; // Flag that we need a backend fetch; it will be dispatched from render() self.needs_backend_fetch = true; } @@ -587,8 +568,9 @@ impl ScreenLike for ContactDetailsScreen { action } - fn display_message(&mut self, message: &str, message_type: MessageType) { - self.display_message(message, message_type); + fn display_message(&mut self, _message: &str, _message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. + self.loading = false; } fn display_task_result(&mut self, result: BackendTaskSuccessResult) { @@ -656,7 +638,11 @@ impl ScreenLike for ContactDetailsScreen { } BackendTaskSuccessResult::DashPayContactInfoUpdated(contact_id) => { if contact_id == self.contact_id { - self.display_message("Contact info saved to Platform", MessageType::Success); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Contact info saved to Platform", + MessageType::Success, + ); } } BackendTaskSuccessResult::DashPayContactsWithInfo(contacts_data) => { diff --git a/src/ui/dashpay/contact_info_editor.rs b/src/ui/dashpay/contact_info_editor.rs index d2b0d74d5..d755f9daa 100644 --- a/src/ui/dashpay/contact_info_editor.rs +++ b/src/ui/dashpay/contact_info_editor.rs @@ -12,6 +12,7 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; +use crate::ui::components::{MessageBanner, ResultBannerExt}; use crate::ui::dashpay::DashPaySubscreen; use crate::ui::identities::get_selected_wallet; use crate::ui::theme::DashColors; @@ -37,7 +38,6 @@ pub struct ContactInfoEditorScreen { is_hidden: bool, accepted_accounts: Vec, account_input: String, - message: Option<(String, MessageType)>, saving: bool, show_info_popup: bool, selected_wallet: Option>>, @@ -51,9 +51,9 @@ impl ContactInfoEditorScreen { contact_id: Identifier, ) -> Self { // Get wallet for the identity - let mut error_message = None; - let selected_wallet = - get_selected_wallet(&identity, Some(&app_context), None, &mut error_message); + let selected_wallet = get_selected_wallet(&identity, Some(&app_context), None) + .or_show_error(app_context.egui_ctx()) + .unwrap_or(None); Self { app_context, @@ -65,7 +65,6 @@ impl ContactInfoEditorScreen { is_hidden: false, accepted_accounts: Vec::new(), account_input: String::new(), - message: None, saving: false, show_info_popup: false, selected_wallet, @@ -135,18 +134,6 @@ impl ContactInfoEditorScreen { ui.separator(); - // Show message if any - if let Some((message, message_type)) = &self.message { - let color = match message_type { - MessageType::Success => DashColors::SUCCESS, - MessageType::Error => DashColors::ERROR, - MessageType::Warning => DashColors::WARNING, - MessageType::Info => DashColors::INFO, - }; - ui.colored_label(color, message); - ui.separator(); - } - ScrollArea::vertical().show(ui, |ui| { ui.group(|ui| { // Contact identity @@ -247,7 +234,7 @@ impl ContactInfoEditorScreen { // Check wallet lock status before showing save button let wallet_locked = if let Some(wallet) = &self.selected_wallet { if let Err(e) = try_open_wallet_no_password(wallet) { - self.message = Some((e, MessageType::Error)); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } wallet_needs_unlock(wallet) } else { @@ -298,21 +285,21 @@ impl ContactInfoEditorScreen { action } - pub fn display_message(&mut self, message: &str, message_type: MessageType) { - self.message = Some((message.to_string(), message_type)); + pub fn display_message(&mut self, _message: &str, _message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. } pub fn display_task_result(&mut self, result: BackendTaskSuccessResult) { self.saving = false; match result { - BackendTaskSuccessResult::Message(msg) => { - self.display_message(&msg, MessageType::Success); + BackendTaskSuccessResult::Message(_msg) => { + // Message display is handled globally by AppState } BackendTaskSuccessResult::DashPayContactsWithInfo(contacts_data) => { self.handle_contacts_result(contacts_data); } _ => { - self.display_message("Contact information updated", MessageType::Success); + // Message display is handled globally by AppState } } } diff --git a/src/ui/dashpay/contact_profile_viewer.rs b/src/ui/dashpay/contact_profile_viewer.rs index 6f72a214f..b61b6ef64 100644 --- a/src/ui/dashpay/contact_profile_viewer.rs +++ b/src/ui/dashpay/contact_profile_viewer.rs @@ -17,7 +17,7 @@ use dash_sdk::platform::Identifier; use egui::{ColorImage, RichText, ScrollArea, TextureHandle, Ui}; use std::collections::HashMap; use std::sync::Arc; -use tracing::warn; +use tracing::error; const PUBLIC_PROFILE_INFO_TEXT: &str = "About Public Profiles:\n\n\ This is the contact's public DashPay profile.\n\n\ @@ -44,7 +44,6 @@ pub struct ContactProfileViewerScreen { pub identity: QualifiedIdentity, pub contact_id: Identifier, profile: Option, - message: Option<(String, MessageType)>, loading: bool, initial_fetch_done: bool, // Private contact info fields @@ -103,7 +102,6 @@ impl ContactProfileViewerScreen { identity, contact_id, profile, - message: None, loading: false, initial_fetch_done, // If we have cached data, don't auto-fetch nickname, @@ -119,7 +117,6 @@ impl ContactProfileViewerScreen { fn fetch_profile(&mut self) -> AppAction { self.loading = true; self.profile = None; // Clear any existing profile - self.message = None; // Clear any existing message let task = BackendTask::DashPayTask(Box::new(DashPayTask::FetchContactProfile { identity: self.identity.clone(), @@ -193,7 +190,7 @@ impl ContactProfileViewerScreen { } } Err(e) => { - warn!("Failed to fetch contact avatar image: {}", e); + error!("Failed to fetch contact avatar image: {}", e); } } }); @@ -225,18 +222,6 @@ impl ContactProfileViewerScreen { ui.separator(); - // Show message if any - if let Some((message, message_type)) = &self.message { - let color = match message_type { - MessageType::Success => DashColors::success_color(dark_mode), - MessageType::Error => DashColors::error_color(dark_mode), - MessageType::Warning => DashColors::warning_color(dark_mode), - MessageType::Info => DashColors::DASH_BLUE, - }; - ui.colored_label(color, message); - ui.separator(); - } - // Loading indicator if self.loading { ui.horizontal(|ui| { @@ -540,16 +525,18 @@ impl ContactProfileViewerScreen { match self.save_private_info() { Ok(_) => { self.editing_private_info = false; - self.message = Some(( - "Private info saved".to_string(), + crate::ui::components::MessageBanner::set_global( + ui.ctx(), + "Private info saved", MessageType::Success, - )); + ); } Err(e) => { - self.message = Some(( + crate::ui::components::MessageBanner::set_global( + ui.ctx(), format!("Failed to save: {}", e), MessageType::Error, - )); + ); } } } @@ -639,15 +626,14 @@ impl ContactProfileViewerScreen { action } - pub fn display_message(&mut self, message: &str, message_type: MessageType) { + pub fn display_message(&mut self, _message: &str, _message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. self.loading = false; - self.message = Some((message.to_string(), message_type)); } pub fn refresh(&mut self) { // Don't auto-fetch on refresh - just clear temporary states self.loading = false; - self.message = None; } pub fn refresh_on_arrival(&mut self) { @@ -742,15 +728,12 @@ impl ScreenLike for ContactProfileViewerScreen { // Note: We don't save to database here - that should only happen // when actually adding them as a contact, not just viewing their profile - - self.message = None; } else { self.profile = None; - self.message = None; // Don't set message here, UI already shows "No profile found" } } - BackendTaskSuccessResult::Message(msg) => { - self.message = Some((msg, MessageType::Info)); + BackendTaskSuccessResult::Message(_msg) => { + // Message display is handled globally by AppState } _ => { // Ignore other results diff --git a/src/ui/dashpay/contact_requests.rs b/src/ui/dashpay/contact_requests.rs index 822d62238..f2f2af90f 100644 --- a/src/ui/dashpay/contact_requests.rs +++ b/src/ui/dashpay/contact_requests.rs @@ -11,6 +11,7 @@ use crate::ui::components::identity_selector::IdentitySelector; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; +use crate::ui::components::{MessageBanner, ResultBannerExt}; use crate::ui::identities::get_selected_wallet; use crate::ui::identities::keys::add_key_screen::AddKeyScreen; use crate::ui::theme::DashColors; @@ -56,7 +57,6 @@ pub struct ContactRequests { selected_identity: Option, selected_identity_string: String, active_tab: RequestTab, - message: Option<(String, MessageType)>, loading: bool, has_fetched_requests: bool, accept_confirmation_dialog: Option<(ConfirmationDialog, ContactRequest)>, @@ -80,7 +80,6 @@ impl ContactRequests { selected_identity: None, selected_identity_string: String::new(), active_tab: RequestTab::Incoming, - message: None, loading: false, has_fetched_requests: false, accept_confirmation_dialog: None, @@ -103,9 +102,10 @@ impl ContactRequests { .to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58); // Get wallet for the selected identity - let mut error_message = None; new_self.selected_wallet = - get_selected_wallet(&identities[0], Some(&app_context), None, &mut error_message); + get_selected_wallet(&identities[0], Some(&app_context), None) + .or_show_error(app_context.egui_ctx()) + .unwrap_or(None); // Load requests from database for this identity new_self.load_requests_from_database(); @@ -131,9 +131,9 @@ impl ContactRequests { .to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58); // Update wallet for the newly selected identity - let mut error_message = None; - self.selected_wallet = - get_selected_wallet(id, Some(&self.app_context), None, &mut error_message); + self.selected_wallet = get_selected_wallet(id, Some(&self.app_context), None) + .or_show_error(self.app_context.egui_ctx()) + .unwrap_or(None); } else { self.selected_identity_string.clear(); self.selected_wallet = None; @@ -142,7 +142,6 @@ impl ContactRequests { // Clear the requests when identity changes self.incoming_requests.clear(); self.outgoing_requests.clear(); - self.message = None; self.has_fetched_requests = false; self.pending_profile_fetches.clear(); @@ -438,7 +437,6 @@ impl ContactRequests { // Only fetch if we have a selected identity if let Some(identity) = &self.selected_identity { self.loading = true; - self.message = None; let task = BackendTask::DashPayTask(Box::new(DashPayTask::LoadContactRequests { identity: identity.clone(), @@ -467,7 +465,6 @@ impl ContactRequests { pub fn refresh(&mut self) -> AppAction { // Don't clear requests - preserve loaded state // Only clear temporary states - self.message = None; self.loading = false; // Auto-select first identity if none selected @@ -507,10 +504,6 @@ impl ContactRequests { if let Some(identity) = &self.selected_identity { // Don't mark as accepted yet - wait for backend confirmation self.loading = true; - self.message = Some(( - "Accepting contact request...".to_string(), - MessageType::Info, - )); let task = BackendTask::DashPayTask(Box::new(DashPayTask::AcceptContactRequest { @@ -532,10 +525,6 @@ impl ContactRequests { if response.inner.dialog_response == Some(ConfirmationStatus::Confirmed) { if let Some(identity) = &self.selected_identity { self.loading = true; - self.message = Some(( - "Rejecting contact request...".to_string(), - MessageType::Info, - )); // Don't mark as rejected yet - wait for backend confirmation @@ -582,19 +571,15 @@ impl ContactRequests { // Clear the requests when identity changes self.incoming_requests.clear(); self.outgoing_requests.clear(); - self.message = None; self.has_fetched_requests = false; self.pending_profile_fetches.clear(); // Update wallet for the newly selected identity if let Some(identity) = &self.selected_identity { - let mut error_message = None; - self.selected_wallet = get_selected_wallet( - identity, - Some(&self.app_context), - None, - &mut error_message, - ); + self.selected_wallet = + get_selected_wallet(identity, Some(&self.app_context), None) + .or_show_error(self.app_context.egui_ctx()) + .unwrap_or(None); } else { self.selected_wallet = None; } @@ -646,21 +631,6 @@ impl ContactRequests { ui.separator(); } - // Show regular message if any (non-error) - if let Some((message, message_type)) = &self.message { - let color = match message_type { - MessageType::Success => egui::Color32::DARK_GREEN, - MessageType::Error => egui::Color32::DARK_RED, - MessageType::Warning => DashColors::WARNING, - MessageType::Info => egui::Color32::LIGHT_BLUE, - }; - // Only show error messages here if there's no structured error - if message_type == &MessageType::Error && self.error.is_none() { - ui.colored_label(color, RichText::new(message).strong()); - ui.separator(); - } - } - if self.selected_identity.is_none() { ui.label("Please select an identity to view contact requests"); return action; @@ -729,13 +699,7 @@ impl ContactRequests { if self.loading { ui.horizontal(|ui| { ui.add(egui::widgets::Spinner::default().color(DashColors::DASH_BLUE)); - - // Show specific loading message based on current message - if let Some((msg, _)) = &self.message { - ui.label(msg); - } else { - ui.label("Loading..."); - } + ui.label("Loading..."); }); } else { ScrollArea::vertical().id_salt("incoming_requests_scroll").show(ui, |ui| { @@ -842,7 +806,7 @@ impl ContactRequests { // Check wallet lock status before showing buttons let wallet_locked = if let Some(wallet) = &self.selected_wallet { if let Err(e) = try_open_wallet_no_password(wallet) { - self.message = Some((e, MessageType::Error)); + crate::ui::components::MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } wallet_needs_unlock(wallet) } else { @@ -914,13 +878,7 @@ impl ContactRequests { if self.loading { ui.horizontal(|ui| { ui.add(egui::widgets::Spinner::default().color(DashColors::DASH_BLUE)); - - // Show specific loading message based on current message - if let Some((msg, _)) = &self.message { - ui.label(msg); - } else { - ui.label("Loading..."); - } + ui.label("Loading..."); }); } else { ScrollArea::vertical().id_salt("outgoing_requests_scroll").show(ui, |ui| { @@ -1073,23 +1031,17 @@ impl ScreenLike for ContactRequests { } fn display_message(&mut self, message: &str, message_type: MessageType) { - // Clear loading state when displaying any message (including errors) + // Banner display is handled globally by AppState; this is only for side-effects. self.loading = false; // Check if this is an error about missing keys - if message_type == MessageType::Error { + if matches!(message_type, MessageType::Error | MessageType::Warning) { if message.contains("ENCRYPTION key") { self.error = Some(DashPayError::MissingEncryptionKey); - self.message = None; - return; } else if message.contains("DECRYPTION key") { self.error = Some(DashPayError::MissingDecryptionKey); - self.message = None; - return; } } - - self.message = Some((message.to_string(), message_type)); } fn display_task_result(&mut self, result: BackendTaskSuccessResult) { @@ -1231,30 +1183,32 @@ impl ScreenLike for ContactRequests { BackendTaskSuccessResult::DashPayContactRequestAccepted(request_id) => { // Mark as accepted only after successful backend operation self.accepted_requests.insert(request_id); - self.message = Some(( - "Contact request accepted successfully".to_string(), + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Contact request accepted successfully", MessageType::Success, - )); + ); } BackendTaskSuccessResult::DashPayContactRequestRejected(request_id) => { // Mark as rejected only after successful backend operation self.rejected_requests.insert(request_id); - self.message = Some(("Contact request rejected".to_string(), MessageType::Success)); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Contact request rejected", + MessageType::Success, + ); } BackendTaskSuccessResult::DashPayContactAlreadyEstablished(_) => { - self.message = Some(("Contact already established".to_string(), MessageType::Info)); + // Message display is handled globally by AppState } BackendTaskSuccessResult::Message(msg) => { // Check if this is an error message about missing keys if msg.contains("ENCRYPTION key") { self.error = Some(DashPayError::MissingEncryptionKey); - self.message = None; } else if msg.contains("DECRYPTION key") { self.error = Some(DashPayError::MissingDecryptionKey); - self.message = None; - } else { - self.message = Some((msg, MessageType::Success)); } + // Other messages are handled globally by AppState } _ => { // Ignore other results diff --git a/src/ui/dashpay/contacts_list.rs b/src/ui/dashpay/contacts_list.rs index 34cc13a76..a6405ce60 100644 --- a/src/ui/dashpay/contacts_list.rs +++ b/src/ui/dashpay/contacts_list.rs @@ -4,6 +4,7 @@ use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::context::AppContext; use crate::model::qualified_identity::QualifiedIdentity; +use crate::ui::components::MessageBanner; use crate::ui::components::identity_selector::IdentitySelector; use crate::ui::components::wallet_unlock_popup::WalletUnlockResult; use crate::ui::dashpay::contact_requests::ContactRequests; @@ -26,7 +27,6 @@ pub struct Contact { pub nickname: Option, pub is_hidden: bool, pub account_reference: u32, - pub created_at: Option, } #[derive(Debug, Clone, PartialEq)] @@ -35,7 +35,7 @@ pub enum SearchFilter { WithUsernames, // Only contacts with usernames WithoutUsernames, // Only contacts without usernames WithBio, // Contacts with bio - Recent, // Added within the last 7 days + Recent, // Recently added (TODO: needs database timestamp) Hidden, // Only hidden contacts Visible, // Only visible contacts } @@ -44,7 +44,7 @@ pub enum SearchFilter { pub enum SortOrder { Name, // Sort by display name/username Username, // Sort by username specifically - DateAdded, // Sort by date added (from database timestamp) + DateAdded, // Sort by date added (TODO: needs database timestamp) AccountRef, // Sort by account reference number } @@ -61,7 +61,6 @@ pub struct ContactsList { selected_identity: Option, selected_identity_string: String, search_query: String, - message: Option<(String, MessageType)>, loading: bool, has_loaded: bool, // Track if we've ever loaded contacts show_hidden: bool, @@ -83,7 +82,6 @@ impl ContactsList { selected_identity: None, selected_identity_string: String::new(), search_query: String::new(), - message: None, loading: false, has_loaded: false, show_hidden: false, @@ -141,7 +139,6 @@ impl ContactsList { nickname: None, // Will be loaded separately from contact_private_info is_hidden: false, // Will be loaded separately from contact_private_info account_reference: 0, // This would need to be loaded from contactInfo document - created_at: Some(stored_contact.created_at), }; // Only add if contact status is accepted @@ -178,7 +175,6 @@ impl ContactsList { // Only fetch if we have a selected identity if let Some(identity) = &self.selected_identity { self.loading = true; - self.message = None; // Clear any existing message let task = BackendTask::DashPayTask(Box::new(DashPayTask::LoadContacts { identity: identity.clone(), @@ -206,7 +202,6 @@ impl ContactsList { pub fn refresh(&mut self) -> AppAction { // Don't clear contacts - preserve loaded state // Only clear temporary states - self.message = None; self.loading = false; // Auto-select first identity if none selected @@ -322,7 +317,6 @@ impl ContactsList { self.contacts.clear(); self.avatar_textures.clear(); self.avatars_loading.clear(); - self.message = None; self.loading = false; // Load contacts from database for the newly selected identity @@ -480,11 +474,6 @@ impl ContactsList { SearchFilter::WithBio, "With bio", ); - ui.selectable_value( - &mut self.search_filter, - SearchFilter::Recent, - "Recent", - ); ui.selectable_value( &mut self.search_filter, SearchFilter::Hidden, @@ -520,11 +509,6 @@ impl ContactsList { SortOrder::Username, "Username", ); - ui.selectable_value( - &mut self.sort_order, - SortOrder::DateAdded, - "Date added", - ); ui.selectable_value( &mut self.sort_order, SortOrder::AccountRef, @@ -542,18 +526,6 @@ impl ContactsList { } } - // Show message if any - if let Some((message, message_type)) = &self.message { - let color = match message_type { - MessageType::Success => egui::Color32::DARK_GREEN, - MessageType::Error => egui::Color32::DARK_RED, - MessageType::Warning => DashColors::WARNING, - MessageType::Info => egui::Color32::LIGHT_BLUE, - }; - ui.colored_label(color, message); - ui.separator(); - } - // Loading indicator if self.loading { ui.horizontal(|ui| { @@ -588,15 +560,8 @@ impl ContactsList { SearchFilter::Hidden if !contact.is_hidden => return false, SearchFilter::Visible if contact.is_hidden => return false, SearchFilter::Recent => { - let seven_days_ago = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() as i64 - - 7 * 24 * 60 * 60; - match contact.created_at { - Some(ts) if ts >= seven_days_ago => {} - _ => return false, - } + // TODO: Implement when we have timestamp data + // For now, treat as "All" } _ => {} // SearchFilter::All or other cases pass through } @@ -690,9 +655,9 @@ impl ContactsList { } SortOrder::AccountRef => a.account_reference.cmp(&b.account_reference), SortOrder::DateAdded => { - // Sort by created_at descending (newest first) - // Contacts without timestamps sort last - b.created_at.unwrap_or(0).cmp(&a.created_at.unwrap_or(0)) + // TODO: Implement when we have timestamp data + // For now, sort by identity ID as a proxy + a.identity_id.cmp(&b.identity_id) } } }); @@ -943,10 +908,15 @@ impl ContactsList { new_hidden, ) { - self.message = Some(( + tracing::error!( + "Failed to update contact: {}", + e + ); + MessageBanner::set_global( + ui.ctx(), format!("Failed to update contact: {}", e), MessageType::Error, - )); + ); } else { // Update the contact in memory if let Some(c) = @@ -997,8 +967,8 @@ impl ContactsList { action } - pub fn display_message(&mut self, message: &str, message_type: MessageType) { - self.message = Some((message.to_string(), message_type)); + pub fn display_message(&mut self, _message: &str, _message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. } } @@ -1018,9 +988,9 @@ impl ScreenLike for ContactsList { action } - fn display_message(&mut self, message: &str, message_type: MessageType) { + fn display_message(&mut self, _message: &str, _message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. self.loading = false; - self.message = Some((message.to_string(), message_type)); } fn display_task_result(&mut self, result: BackendTaskSuccessResult) { @@ -1045,14 +1015,12 @@ impl ScreenLike for ContactsList { nickname: None, is_hidden: false, account_reference: 0, - created_at: None, }; self.contacts.insert(contact_id, contact); } - // Mark as loaded and clear message + // Mark as loaded self.has_loaded = true; - self.message = None; } BackendTaskSuccessResult::DashPayContactsWithInfo(contacts_data) => { // Clear existing contacts @@ -1065,13 +1033,10 @@ impl ScreenLike for ContactsList { // Clear all existing contacts for this identity from database first // This prevents stale contacts from persisting - if let Err(e) = self + let _ = self .app_context .db - .clear_dashpay_contacts(&owner_id, &network_str) - { - tracing::warn!("Failed to clear dashpay contacts from database: {}", e); - } + .clear_dashpay_contacts(&owner_id, &network_str); // Convert ContactData to Contact structs and save to database for contact_data in contacts_data { @@ -1093,17 +1058,11 @@ impl ScreenLike for ContactsList { nickname: contact_data.nickname.clone(), is_hidden: contact_data.is_hidden, account_reference: contact_data.account_reference, - created_at: Some( - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() as i64, - ), // Fallback to current time for filter/sort }; self.contacts.insert(contact_data.identity_id, contact); // Save to database - if let Err(e) = self.app_context.db.save_dashpay_contact( + let _ = self.app_context.db.save_dashpay_contact( &owner_id, &contact_data.identity_id, &network_str, @@ -1112,23 +1071,16 @@ impl ScreenLike for ContactsList { contact_data.avatar_url.as_deref(), None, // public_message - not yet fetched "accepted", // Only accepted contacts are returned from load_contacts - ) { - tracing::warn!("Failed to save dashpay contact to database: {}", e); - } + ); // Save private info if present - if let Some(nickname) = &contact_data.nickname - && let Err(e) = self.app_context.db.save_contact_private_info( + if let Some(nickname) = &contact_data.nickname { + let _ = self.app_context.db.save_contact_private_info( &owner_id, &contact_data.identity_id, nickname, &contact_data.note.unwrap_or_default(), contact_data.is_hidden, - ) - { - tracing::warn!( - "Failed to save contact private info to database: {}", - e ); } } @@ -1149,20 +1101,13 @@ impl ScreenLike for ContactsList { nickname: contact_data.nickname, is_hidden: contact_data.is_hidden, account_reference: contact_data.account_reference, - created_at: Some( - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() as i64, - ), // Fallback to current time for filter/sort }; self.contacts.insert(contact_data.identity_id, contact); } } - // Mark as loaded and clear message + // Mark as loaded self.has_loaded = true; - self.message = None; } BackendTaskSuccessResult::DashPayContactProfile(Some(doc)) => { // Extract profile information from the document @@ -1206,7 +1151,7 @@ impl ScreenLike for ContactsList { if let Some(identity) = &self.selected_identity { let owner_id = identity.identity.id(); let network_str = self.app_context.network.to_string(); - if let Err(e) = self.app_context.db.save_dashpay_contact( + let _ = self.app_context.db.save_dashpay_contact( &owner_id, &contact_id, &network_str, @@ -1215,12 +1160,7 @@ impl ScreenLike for ContactsList { contact.avatar_url.as_deref(), public_message.as_deref(), "accepted", - ) { - tracing::warn!( - "Failed to save updated contact profile to database: {}", - e - ); - } + ); } } } diff --git a/src/ui/dashpay/profile_screen.rs b/src/ui/dashpay/profile_screen.rs index a97aac44b..51688779b 100644 --- a/src/ui/dashpay/profile_screen.rs +++ b/src/ui/dashpay/profile_screen.rs @@ -13,6 +13,7 @@ use crate::ui::components::info_popup::InfoPopup; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; +use crate::ui::components::{MessageBanner, ResultBannerExt}; use crate::ui::identities::get_selected_wallet; use crate::ui::theme::DashColors; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; @@ -84,7 +85,6 @@ pub struct ProfileScreen { edit_display_name: String, edit_bio: String, edit_avatar_url: String, - message: Option<(String, MessageType)>, loading: bool, saving: bool, // Track if we're saving vs loading profile_load_attempted: bool, @@ -117,7 +117,6 @@ impl ProfileScreen { edit_display_name: String::new(), edit_bio: String::new(), edit_avatar_url: String::new(), - message: None, loading: false, saving: false, profile_load_attempted: false, @@ -202,13 +201,10 @@ impl ProfileScreen { ); // Get wallet for the selected identity - let mut error_message = None; - new_self.selected_wallet = get_selected_wallet( - &identities[selected_idx], - Some(&app_context), - None, - &mut error_message, - ); + new_self.selected_wallet = + get_selected_wallet(&identities[selected_idx], Some(&app_context), None) + .or_show_error(app_context.egui_ctx()) + .unwrap_or(None); // Load profile from database for this identity new_self.load_profile_from_database(); @@ -346,9 +342,6 @@ impl ProfileScreen { // This prevents stuck loading states self.loading = false; - // Clear any old messages - self.message = None; - // Auto-select first identity if none selected if self.selected_identity.is_none() && let Ok(identities) = self.app_context.load_local_qualified_identities() @@ -392,14 +385,17 @@ impl ProfileScreen { self.editing = true; self.has_unsaved_changes = false; self.validation_errors.clear(); - self.message = None; } fn save_profile(&mut self) -> AppAction { self.validate_profile(); if !self.is_valid() { - self.display_message(&self.validation_errors[0].message(), MessageType::Error); + MessageBanner::set_global( + self.app_context.egui_ctx(), + self.validation_errors[0].message(), + MessageType::Error, + ); return AppAction::None; } @@ -437,7 +433,11 @@ impl ProfileScreen { }, ))) } else { - self.display_message("No identity selected", MessageType::Error); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "No identity selected", + MessageType::Error, + ); AppAction::None } } @@ -449,7 +449,6 @@ impl ProfileScreen { self.edit_avatar_url.clear(); self.validation_errors.clear(); self.has_unsaved_changes = false; - self.message = None; } /// Load avatar texture from network (fetches bytes and processes them) @@ -626,19 +625,15 @@ impl ProfileScreen { self.editing = false; self.validation_errors.clear(); self.has_unsaved_changes = false; - self.message = None; self.avatar_loading = false; // Don't clear avatar_textures - they're keyed by URL so can be reused // Update wallet for the newly selected identity if let Some(identity) = &self.selected_identity { - let mut error_message = None; - self.selected_wallet = get_selected_wallet( - identity, - Some(&self.app_context), - None, - &mut error_message, - ); + self.selected_wallet = + get_selected_wallet(identity, Some(&self.app_context), None) + .or_show_error(self.app_context.egui_ctx()) + .unwrap_or(None); } else { self.selected_wallet = None; } @@ -656,18 +651,6 @@ impl ProfileScreen { return super::render_no_identities_card(ui, &self.app_context); } - // Show message if any - if let Some((message, message_type)) = &self.message { - let color = match message_type { - MessageType::Success => egui::Color32::DARK_GREEN, - MessageType::Error => egui::Color32::DARK_RED, - MessageType::Warning => DashColors::WARNING, - MessageType::Info => egui::Color32::LIGHT_BLUE, - }; - ui.colored_label(color, message); - ui.separator(); - } - if self.selected_identity.is_none() { ui.label("Please select an identity to view or edit profile"); return action; @@ -885,7 +868,7 @@ impl ProfileScreen { // Check wallet lock status before showing save button let wallet_locked = if let Some(wallet) = &self.selected_wallet { if let Err(e) = try_open_wallet_no_password(wallet) { - self.message = Some((e, MessageType::Error)); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } wallet_needs_unlock(wallet) } else { @@ -1348,7 +1331,8 @@ impl ProfileScreen { ui.horizontal(|ui| { if ui.button("Copy URL").clicked() { ui.ctx().copy_text(avatar_url.clone()); - self.display_message( + MessageBanner::set_global( + ui.ctx(), "Avatar URL copied to clipboard", MessageType::Info, ); @@ -1394,10 +1378,9 @@ impl ProfileScreen { action } - pub fn display_message(&mut self, message: &str, message_type: MessageType) { - self.message = Some((message.to_string(), message_type)); - // Clear loading/saving states on error - if message_type == MessageType::Error { + pub fn display_message(&mut self, _message: &str, message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. + if matches!(message_type, MessageType::Error | MessageType::Warning) { self.loading = false; self.saving = false; } diff --git a/src/ui/dashpay/profile_search.rs b/src/ui/dashpay/profile_search.rs index a22810f4e..9f10f23ac 100644 --- a/src/ui/dashpay/profile_search.rs +++ b/src/ui/dashpay/profile_search.rs @@ -34,7 +34,6 @@ pub struct ProfileSearchScreen { pub app_context: Arc, search_query: String, search_results: Vec, - message: Option<(String, MessageType)>, loading: bool, has_searched: bool, // Track if a search has been performed show_info_popup: bool, @@ -46,16 +45,20 @@ impl ProfileSearchScreen { app_context, search_query: String::new(), search_results: Vec::new(), - message: None, loading: false, has_searched: false, show_info_popup: false, } } - fn search_profiles(&mut self) -> AppAction { + fn search_profiles(&mut self, ctx: &egui::Context) -> AppAction { if self.search_query.trim().is_empty() { - self.display_message("Please enter a search term", MessageType::Error); + self.loading = false; + crate::ui::components::MessageBanner::set_global( + ctx, + "Please enter a search term", + MessageType::Error, + ); return AppAction::None; } @@ -70,14 +73,15 @@ impl ProfileSearchScreen { AppAction::BackendTask(task) } - fn view_profile(&mut self, identity_id: Identifier) -> AppAction { + fn view_profile(&mut self, ctx: &egui::Context, identity_id: Identifier) -> AppAction { // Use any available identity for viewing (just needed for context) let identities = self .app_context .load_local_qualified_identities() .unwrap_or_default(); if identities.is_empty() { - self.display_message( + crate::ui::components::MessageBanner::set_global( + ctx, "No identities available. Please load an identity first.", MessageType::Error, ); @@ -117,18 +121,6 @@ impl ProfileSearchScreen { ui.separator(); - // Show message if any - if let Some((message, message_type)) = &self.message { - let color = match message_type { - MessageType::Success => DashColors::success_color(dark_mode), - MessageType::Error => DashColors::error_color(dark_mode), - MessageType::Warning => DashColors::warning_color(dark_mode), - MessageType::Info => DashColors::DASH_BLUE, - }; - ui.colored_label(color, message); - ui.separator(); - } - ScrollArea::vertical().show(ui, |ui| { // Search section ui.horizontal(|ui| { @@ -142,12 +134,12 @@ impl ProfileSearchScreen { // Trigger search on Enter key if response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { - action = self.search_profiles(); + action = self.search_profiles(ui.ctx()); } }); if ui.button("Search").clicked() { - action = self.search_profiles(); + action = self.search_profiles(ui.ctx()); } }); @@ -233,7 +225,7 @@ impl ProfileSearchScreen { egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui.button("View Profile").clicked() { - action = self.view_profile(result.identity_id); + action = self.view_profile(ui.ctx(), result.identity_id); } if ui.button("Add Contact").clicked() { action = self.add_contact(result.identity_id); @@ -258,9 +250,9 @@ impl ProfileSearchScreen { action } - pub fn display_message(&mut self, message: &str, message_type: MessageType) { + pub fn display_message(&mut self, _message: &str, _message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. self.loading = false; - self.message = Some((message.to_string(), message_type)); } } @@ -302,7 +294,6 @@ impl ScreenLike for ProfileSearchScreen { self.search_query.clear(); self.search_results.clear(); self.has_searched = false; - self.message = None; action = AppAction::None; // Consume the action } @@ -371,8 +362,8 @@ impl ScreenLike for ProfileSearchScreen { self.search_results.push(search_result); } } - BackendTaskSuccessResult::Message(msg) => { - self.message = Some((msg, MessageType::Info)); + BackendTaskSuccessResult::Message(_msg) => { + // Message display is handled globally by AppState } _ => { // Ignore other results diff --git a/src/ui/dashpay/qr_code_generator.rs b/src/ui/dashpay/qr_code_generator.rs index ac76a407e..fd721de36 100644 --- a/src/ui/dashpay/qr_code_generator.rs +++ b/src/ui/dashpay/qr_code_generator.rs @@ -12,6 +12,7 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; +use crate::ui::components::{MessageBanner, ResultBannerExt}; use crate::ui::dashpay::dashpay_screen::DashPaySubscreen; use crate::ui::identities::funding_common::generate_qr_code_image; use crate::ui::identities::get_selected_wallet; @@ -42,7 +43,6 @@ pub struct QRCodeGeneratorScreen { account_index: String, validity_hours: String, generated_qr_data: Option, - message: Option<(String, MessageType)>, show_info_popup: bool, show_advanced_options: bool, selected_wallet: Option>>, @@ -58,7 +58,6 @@ impl QRCodeGeneratorScreen { account_index: "0".to_string(), validity_hours: "24".to_string(), generated_qr_data: None, - message: None, show_info_popup: false, show_advanced_options: false, selected_wallet: None, @@ -77,9 +76,10 @@ impl QRCodeGeneratorScreen { identities[0].identity.id().to_string(Encoding::Base58); // Get wallet for the selected identity - let mut error_message = None; new_self.selected_wallet = - get_selected_wallet(&identities[0], Some(&app_context), None, &mut error_message); + get_selected_wallet(&identities[0], Some(&app_context), None) + .or_show_error(app_context.egui_ctx()) + .unwrap_or(None); } new_self @@ -90,7 +90,11 @@ impl QRCodeGeneratorScreen { let account_idx = match self.account_index.parse::() { Ok(v) => v, Err(_) => { - self.display_message("Invalid account index number", MessageType::Error); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Invalid account index number", + MessageType::Error, + ); return; } }; @@ -98,7 +102,8 @@ impl QRCodeGeneratorScreen { let validity = match self.validity_hours.parse::() { Ok(v) if v > 0 && v <= 720 => v, // Max 30 days _ => { - self.display_message( + MessageBanner::set_global( + self.app_context.egui_ctx(), "Validity hours must be between 1 and 720", MessageType::Error, ); @@ -110,17 +115,26 @@ impl QRCodeGeneratorScreen { Ok(proof_data) => { let qr_string = proof_data.to_qr_string(); self.generated_qr_data = Some(qr_string); - self.display_message("QR code generated successfully", MessageType::Success); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "QR code generated successfully", + MessageType::Success, + ); } Err(e) => { - self.display_message( - &format!("Failed to generate QR code: {}", e), + MessageBanner::set_global( + self.app_context.egui_ctx(), + format!("Failed to generate QR code: {}", e), MessageType::Error, ); } } } else { - self.display_message("Please select an identity first", MessageType::Error); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Please select an identity first", + MessageType::Error, + ); } } @@ -145,18 +159,6 @@ impl QRCodeGeneratorScreen { ui.separator(); - // Show message if any - if let Some((message, message_type)) = &self.message { - let color = match message_type { - MessageType::Success => DashColors::success_color(dark_mode), - MessageType::Error => DashColors::error_color(dark_mode), - MessageType::Warning => DashColors::warning_color(dark_mode), - MessageType::Info => DashColors::DASH_BLUE, - }; - ui.colored_label(color, message); - ui.separator(); - } - // Identity selector let identities = self .app_context @@ -201,19 +203,18 @@ impl QRCodeGeneratorScreen { if response.changed() { // Update wallet for the newly selected identity if let Some(identity) = &self.selected_identity { - let mut error_message = None; self.selected_wallet = get_selected_wallet( identity, Some(&self.app_context), None, - &mut error_message, - ); + ) + .or_show_error(self.app_context.egui_ctx()) + .unwrap_or(None); } else { self.selected_wallet = None; } // Clear generated QR code when identity changes self.generated_qr_data = None; - self.message = None; } }); ui.end_row(); @@ -265,7 +266,7 @@ impl QRCodeGeneratorScreen { // Check wallet lock status before showing generate button let wallet_locked = if let Some(wallet) = &self.selected_wallet { if let Err(e) = try_open_wallet_no_password(wallet) { - self.message = Some((e, MessageType::Error)); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } wallet_needs_unlock(wallet) } else { @@ -293,7 +294,6 @@ impl QRCodeGeneratorScreen { if self.generated_qr_data.is_some() && ui.button("Clear").clicked() { self.generated_qr_data = None; - self.message = None; } }); } @@ -368,7 +368,7 @@ impl QRCodeGeneratorScreen { } if show_copied_message { - self.display_message("Copied to clipboard", MessageType::Success); + MessageBanner::set_global(ui.ctx(), "Copied to clipboard", MessageType::Success); } }); @@ -387,8 +387,8 @@ impl QRCodeGeneratorScreen { action } - pub fn display_message(&mut self, message: &str, message_type: MessageType) { - self.message = Some((message.to_string(), message_type)); + pub fn display_message(&mut self, _message: &str, _message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. } } @@ -435,7 +435,7 @@ impl ScreenLike for QRCodeGeneratorScreen { action } - fn display_message(&mut self, message: &str, message_type: MessageType) { - self.display_message(message, message_type); + fn display_message(&mut self, _message: &str, _message_type: MessageType) { + // Banner display is handled globally by AppState; no side-effects needed. } } diff --git a/src/ui/dashpay/qr_scanner.rs b/src/ui/dashpay/qr_scanner.rs index 5eafeb4a6..99edfe3f0 100644 --- a/src/ui/dashpay/qr_scanner.rs +++ b/src/ui/dashpay/qr_scanner.rs @@ -13,9 +13,9 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; +use crate::ui::components::{MessageBanner, ResultBannerExt}; use crate::ui::dashpay::dashpay_screen::DashPaySubscreen; use crate::ui::identities::get_selected_wallet; -use crate::ui::theme::DashColors; use crate::ui::{MessageType, RootScreenType, ScreenLike}; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; @@ -29,7 +29,6 @@ pub struct QRScannerScreen { selected_identity_string: String, qr_data_input: String, parsed_qr_data: Option, - message: Option<(String, MessageType)>, sending: bool, selected_wallet: Option>>, wallet_unlock_popup: WalletUnlockPopup, @@ -43,7 +42,6 @@ impl QRScannerScreen { selected_identity_string: String::new(), qr_data_input: String::new(), parsed_qr_data: None, - message: None, sending: false, selected_wallet: None, wallet_unlock_popup: WalletUnlockPopup::new(), @@ -52,18 +50,31 @@ impl QRScannerScreen { fn parse_qr_code(&mut self) { if self.qr_data_input.is_empty() { - self.display_message("Please enter QR code data", MessageType::Error); + self.parsed_qr_data = None; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Please enter QR code data", + MessageType::Error, + ); return; } match AutoAcceptProofData::from_qr_string(&self.qr_data_input) { Ok(data) => { self.parsed_qr_data = Some(data); - self.display_message("QR code parsed successfully", MessageType::Success); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "QR code parsed successfully", + MessageType::Success, + ); } Err(e) => { self.parsed_qr_data = None; - self.display_message(&format!("Invalid QR code: {}", e), MessageType::Error); + MessageBanner::set_global( + self.app_context.egui_ctx(), + format!("Invalid QR code: {}", e), + MessageType::Error, + ); } } } @@ -84,7 +95,11 @@ impl QRScannerScreen { ) { Some(key) => key, None => { - self.display_message("No suitable signing key found. This operation requires a ECDSA_SECP256K1 AUTHENTICATION key.", MessageType::Error); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "No suitable signing key found. This operation requires a ECDSA_SECP256K1 AUTHENTICATION key.", + MessageType::Error, + ); return AppAction::None; } }; @@ -106,10 +121,18 @@ impl QRScannerScreen { return AppAction::BackendTask(task); } else { - self.display_message("Please parse a QR code first", MessageType::Error); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Please parse a QR code first", + MessageType::Error, + ); } } else { - self.display_message("Please select an identity", MessageType::Error); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Please select an identity", + MessageType::Error, + ); } AppAction::None @@ -117,24 +140,11 @@ impl QRScannerScreen { pub fn render(&mut self, ui: &mut Ui) -> AppAction { let mut action = AppAction::None; - let dark_mode = ui.ctx().style().visuals.dark_mode; // Header ui.heading("Scan Contact QR Code"); ui.add_space(10.0); - // Show message if any - if let Some((message, message_type)) = &self.message { - let color = match message_type { - MessageType::Success => DashColors::success_color(dark_mode), - MessageType::Error => DashColors::error_color(dark_mode), - MessageType::Warning => DashColors::warning_color(dark_mode), - MessageType::Info => DashColors::DASH_BLUE, - }; - ui.colored_label(color, message); - ui.add_space(10.0); - } - // Identity selector let identities = self .app_context @@ -174,13 +184,13 @@ impl QRScannerScreen { let new_identity_id = self.selected_identity.as_ref().map(|i| i.identity.id()); if prev_identity_id != new_identity_id { if let Some(identity) = &self.selected_identity { - let mut error_message = None; self.selected_wallet = get_selected_wallet( identity, Some(&self.app_context), None, - &mut error_message, - ); + ) + .or_show_error(self.app_context.egui_ctx()) + .unwrap_or(None); } else { self.selected_wallet = None; } @@ -210,7 +220,6 @@ impl QRScannerScreen { if ui.button("Clear").clicked() { self.qr_data_input.clear(); self.parsed_qr_data = None; - self.message = None; } }); }); @@ -248,7 +257,7 @@ impl QRScannerScreen { // Check wallet lock status before showing send button let wallet_locked = if let Some(wallet) = &self.selected_wallet { if let Err(e) = try_open_wallet_no_password(wallet) { - self.message = Some((e, MessageType::Error)); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } wallet_needs_unlock(wallet) } else { @@ -299,22 +308,16 @@ impl QRScannerScreen { action } - pub fn display_message(&mut self, message: &str, message_type: MessageType) { - self.message = Some((message.to_string(), message_type)); + pub fn display_message(&mut self, _message: &str, _message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. } pub fn display_task_result(&mut self, result: BackendTaskSuccessResult) { self.sending = false; - match result { - BackendTaskSuccessResult::Message(msg) => { - self.display_message(&msg, MessageType::Success); - // Clear the form on success - self.qr_data_input.clear(); - self.parsed_qr_data = None; - } - _ => { - self.display_message("Contact request sent successfully", MessageType::Success); - } + if let BackendTaskSuccessResult::Message(_) = result { + // Clear the form on success + self.qr_data_input.clear(); + self.parsed_qr_data = None; } } } @@ -359,8 +362,8 @@ impl ScreenLike for QRScannerScreen { action } - fn display_message(&mut self, message: &str, message_type: MessageType) { - self.display_message(message, message_type); + fn display_message(&mut self, _message: &str, _message_type: MessageType) { + // Banner display is handled globally by AppState; no side-effects needed. } fn display_task_result(&mut self, result: BackendTaskSuccessResult) { diff --git a/src/ui/dashpay/send_payment.rs b/src/ui/dashpay/send_payment.rs index f3416a09b..5791f373b 100644 --- a/src/ui/dashpay/send_payment.rs +++ b/src/ui/dashpay/send_payment.rs @@ -5,6 +5,7 @@ use crate::context::AppContext; use crate::model::amount::Amount; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; +use crate::ui::components::MessageBanner; use crate::ui::components::amount_input::AmountInput; use crate::ui::components::dashpay_subscreen_chooser_panel::add_dashpay_subscreen_chooser_panel; use crate::ui::components::identity_selector::IdentitySelector; @@ -42,7 +43,6 @@ pub struct SendPaymentScreen { amount_input: Option, amount: Amount, memo: String, - message: Option<(String, MessageType)>, sending: bool, show_info_popup: bool, payment_success: bool, @@ -69,7 +69,6 @@ impl SendPaymentScreen { amount_input: None, amount: Amount::new_dash(0.0), memo: String::new(), - message: None, sending: false, show_info_popup: false, payment_success: false, @@ -103,7 +102,11 @@ impl SendPaymentScreen { fn send_payment(&mut self) -> AppAction { // Validate amount if self.amount.value() == 0 { - self.display_message("Please enter an amount", MessageType::Error); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Please enter an amount", + MessageType::Error, + ); return AppAction::None; } @@ -124,7 +127,7 @@ impl SendPaymentScreen { }; if let Err(e) = wallet_check { - self.display_message(&e, MessageType::Error); + MessageBanner::set_global(self.app_context.egui_ctx(), &e, MessageType::Error); return AppAction::None; } @@ -132,7 +135,11 @@ impl SendPaymentScreen { let amount_dash = match self.amount.dash_to_duffs() { Ok(duffs) => duffs as f64 / 100_000_000.0, Err(e) => { - self.display_message(&format!("Invalid amount: {}", e), MessageType::Error); + MessageBanner::set_global( + self.app_context.egui_ctx(), + format!("Invalid amount: {}", e), + MessageType::Error, + ); return AppAction::None; } }; @@ -195,20 +202,6 @@ impl SendPaymentScreen { ui.separator(); - // Show message if any - if let Some((message, message_type)) = &self.message { - let color = match message_type { - MessageType::Success => egui::Color32::DARK_GREEN, - MessageType::Error => egui::Color32::DARK_RED, - MessageType::Warning => { - DashColors::warning_color(ui.ctx().style().visuals.dark_mode) - } - MessageType::Info => egui::Color32::LIGHT_BLUE, - }; - ui.colored_label(color, message); - ui.separator(); - } - // Check wallet unlock let (wallet_open_error, needs_unlock) = if let Some(wallet) = &self.selected_wallet { let open_err = try_open_wallet_no_password(wallet).err(); @@ -219,7 +212,7 @@ impl SendPaymentScreen { }; if let Some(e) = wallet_open_error { - self.display_message(&e, MessageType::Error); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } if needs_unlock { @@ -369,7 +362,8 @@ impl SendPaymentScreen { if ui.add_enabled(send_enabled, send_button).clicked() { if self.memo.len() > 100 { - self.display_message( + MessageBanner::set_global( + ui.ctx(), "Memo must be 100 characters or less", MessageType::Error, ); @@ -389,8 +383,8 @@ impl SendPaymentScreen { action } - pub fn display_message(&mut self, message: &str, message_type: MessageType) { - self.message = Some((message.to_string(), message_type)); + pub fn display_message(&mut self, _message: &str, _message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. } } @@ -452,21 +446,16 @@ impl ScreenLike for SendPaymentScreen { action } - fn display_message(&mut self, message: &str, message_type: MessageType) { + fn display_message(&mut self, _message: &str, _message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. self.sending = false; - self.message = Some((message.to_string(), message_type)); } fn display_task_result(&mut self, result: BackendTaskSuccessResult) { self.sending = false; - if let BackendTaskSuccessResult::DashPayPaymentSent(recipient, address, amount) = result { - // Extract txid from the address (or we could modify the result to include it) + if let BackendTaskSuccessResult::DashPayPaymentSent(_recipient, address, _amount) = result { self.payment_success = true; self.tx_id = Some(format!("Sent to {}", address)); - self.message = Some(( - format!("Payment of {} DASH sent to {}", amount, recipient), - MessageType::Success, - )); } } } @@ -477,7 +466,6 @@ pub struct PaymentHistory { selected_identity: Option, selected_identity_string: String, payments: Vec, - message: Option<(String, MessageType)>, loading: bool, has_searched: bool, } @@ -499,7 +487,6 @@ impl PaymentHistory { selected_identity: None, selected_identity_string: String::new(), payments: Vec::new(), - message: None, loading: false, has_searched: false, }; @@ -587,7 +574,6 @@ impl PaymentHistory { pub fn trigger_fetch_payment_history(&mut self) -> AppAction { if let Some(identity) = &self.selected_identity { self.loading = true; - self.message = Some(("Loading payment history...".to_string(), MessageType::Info)); let task = BackendTask::DashPayTask(Box::new(DashPayTask::LoadPaymentHistory { identity: identity.clone(), @@ -601,7 +587,6 @@ impl PaymentHistory { pub fn refresh(&mut self) { // Don't clear if we have data, just clear temporary states - self.message = None; self.loading = false; // Auto-select first identity if none selected @@ -662,20 +647,6 @@ impl PaymentHistory { return super::render_no_identities_card(ui, &self.app_context); } - // Show message if any - if let Some((message, message_type)) = &self.message { - let color = match message_type { - MessageType::Success => egui::Color32::DARK_GREEN, - MessageType::Error => egui::Color32::DARK_RED, - MessageType::Warning => { - DashColors::warning_color(ui.ctx().style().visuals.dark_mode) - } - MessageType::Info => egui::Color32::LIGHT_BLUE, - }; - ui.colored_label(color, message); - ui.separator(); - } - if self.selected_identity.is_none() { let dark_mode = ui.ctx().style().visuals.dark_mode; ui.label( @@ -812,8 +783,8 @@ impl PaymentHistory { action } - pub fn display_message(&mut self, message: &str, message_type: MessageType) { - self.message = Some((message.to_string(), message_type)); + pub fn display_message(&mut self, _message: &str, _message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. } pub fn display_task_result(&mut self, result: BackendTaskSuccessResult) { @@ -888,9 +859,6 @@ impl PaymentHistory { self.payments.push(payment); } } - - // Don't show message - let the UI handle empty state - self.message = None; } _ => { // Ignore other results diff --git a/src/ui/dpns/dpns_contested_names_screen.rs b/src/ui/dpns/dpns_contested_names_screen.rs index e749959af..48bf268b2 100644 --- a/src/ui/dpns/dpns_contested_names_screen.rs +++ b/src/ui/dpns/dpns_contested_names_screen.rs @@ -23,6 +23,7 @@ use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::{StyledButton, island_central_panel}; use crate::ui::components::tools_subscreen_chooser_panel::add_tools_subscreen_chooser_panel; use crate::ui::components::top_panel::add_top_panel; +use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt}; use crate::ui::identities::register_dpns_name_screen::RegisterDpnsNameSource; use crate::ui::theme::DashColors; use crate::ui::{BackendTaskSuccessResult, MessageType, RootScreenType, ScreenLike, ScreenType}; @@ -74,7 +75,7 @@ pub enum ScheduledVoteCastingStatus { #[derive(PartialEq)] pub enum VoteHandlingStatus { NotStarted, - CastingVotes(u64), + CastingVotes, SchedulingVotes, Completed, Failed(String), @@ -82,7 +83,7 @@ pub enum VoteHandlingStatus { #[derive(PartialEq)] pub enum RefreshingStatus { - Refreshing(u64), + Refreshing, NotRefreshing, } @@ -117,7 +118,6 @@ pub struct DPNSScreen { pub scheduled_vote_cast_in_progress: bool, pub selected_votes: Vec, pub app_context: Arc, - message: Option<(String, MessageType, DateTime)>, pending_backend_task: Option, /// Sorting @@ -130,12 +130,14 @@ pub struct DPNSScreen { /// Which sub-screen is active: Active contests, Past, Owned, or Scheduled pub dpns_subscreen: DPNSSubscreen, refreshing_status: RefreshingStatus, + refresh_banner: Option, /// Selected vote handling show_bulk_schedule_popup: bool, bulk_identity_options: Vec, bulk_schedule_message: Option<(MessageType, String)>, bulk_vote_handling_status: VoteHandlingStatus, + vote_banner: Option, set_all_option: VoteOption, } @@ -188,7 +190,6 @@ impl DPNSScreen { scheduled_votes: scheduled_votes_with_status, selected_votes: Vec::new(), app_context: app_context.clone(), - message: None, sort_column: SortColumn::ContestedName, sort_order: SortOrder::Ascending, active_filter_term: String::new(), @@ -198,33 +199,18 @@ impl DPNSScreen { pending_backend_task: None, dpns_subscreen, refreshing_status: RefreshingStatus::NotRefreshing, + refresh_banner: None, // Vote handling show_bulk_schedule_popup: false, bulk_identity_options, bulk_schedule_message: None, bulk_vote_handling_status: VoteHandlingStatus::NotStarted, + vote_banner: None, set_all_option: VoteOption::CastNow, } } - // --------------------------- - // Error handling - // --------------------------- - fn dismiss_message(&mut self) { - self.message = None; - } - - fn check_error_expiration(&mut self) { - if let Some((_, _, timestamp)) = &self.message { - let now = Utc::now(); - let elapsed = now.signed_duration_since(*timestamp); - if elapsed.num_seconds() >= 10 { - self.dismiss_message(); - } - } - } - // --------------------------- // Sorting // --------------------------- @@ -308,12 +294,10 @@ impl DPNSScreen { ui.label(RichText::new("Please check back later or try refreshing the list.").color(DashColors::text_primary(dark_mode))); ui.add_space(20.0); if StyledButton::primary("Refresh").show(ui).clicked() { - if let RefreshingStatus::Refreshing(_) = self.refreshing_status { + if let RefreshingStatus::Refreshing = self.refreshing_status { app_action = AppAction::None; } else { - let now = Utc::now().timestamp() as u64; - self.refreshing_status = RefreshingStatus::Refreshing(now); - self.message = None; // Clear any existing message + self.refreshing_status = RefreshingStatus::Refreshing; match self.dpns_subscreen { DPNSSubscreen::Active | DPNSSubscreen::Past => { app_action = AppAction::BackendTask(BackendTask::ContestedResourceTask( @@ -977,13 +961,15 @@ impl DPNSScreen { .db .set_identity_alias(&identifier, Some(&alias_with_suffix)) { - self.display_message( - &format!("Failed to set alias: {}", e), + MessageBanner::set_global( + ui.ctx(), + format!("Failed to set alias: {}", e), MessageType::Error, ); } else { - self.display_message( - &format!( + MessageBanner::set_global( + ui.ctx(), + format!( "Alias set to '{}' for identity {}", alias_with_suffix, identifier.to_string(Encoding::Base58) @@ -1555,6 +1541,13 @@ impl DPNSScreen { .corner_radius(3.0); if ui.add(button).clicked() { action = self.bulk_apply_votes(); + if self.bulk_vote_handling_status == VoteHandlingStatus::CastingVotes { + self.vote_banner.take_and_clear(); + let handle = + MessageBanner::set_global(ui.ctx(), "Casting votes...", MessageType::Info); + handle.with_elapsed(); + self.vote_banner = Some(handle); + } } ui.add_space(5.0); @@ -1563,20 +1556,15 @@ impl DPNSScreen { self.show_bulk_schedule_popup = false; self.bulk_schedule_message = None; self.bulk_vote_handling_status = VoteHandlingStatus::NotStarted; + self.vote_banner.take_and_clear(); } // Handle status ui.add_space(10.0); match &self.bulk_vote_handling_status { VoteHandlingStatus::NotStarted => {} - VoteHandlingStatus::CastingVotes(start_time) => { - let now = Utc::now().timestamp() as u64; - let elapsed = now - start_time; - let dark_mode = ui.ctx().style().visuals.dark_mode; - ui.label( - RichText::new(format!("Casting votes... Time taken so far: {}", elapsed)) - .color(DashColors::text_primary(dark_mode)), - ); + VoteHandlingStatus::CastingVotes => { + // Elapsed time is shown in the global banner } VoteHandlingStatus::SchedulingVotes => { let dark_mode = ui.ctx().style().visuals.dark_mode; @@ -1653,8 +1641,7 @@ impl DPNSScreen { .iter() .map(|sv| (sv.contested_name.clone(), sv.vote_choice)) .collect(); - let now = Utc::now().timestamp() as u64; - self.bulk_vote_handling_status = VoteHandlingStatus::CastingVotes(now); + self.bulk_vote_handling_status = VoteHandlingStatus::CastingVotes; if !scheduled_list.is_empty() { AppAction::BackendTasks( vec![ @@ -1840,7 +1827,11 @@ impl ScreenLike for DPNSScreen { } fn display_message(&mut self, message: &str, message_type: MessageType) { - // Sync error states + // Banner display is handled globally by AppState; this is only for side-effects. + if matches!(message_type, MessageType::Error | MessageType::Warning) { + self.refresh_banner.take_and_clear(); + self.vote_banner.take_and_clear(); + } if message.contains("Error casting scheduled vote") { self.scheduled_vote_cast_in_progress = false; if let Ok(mut guard) = self.scheduled_votes.lock() { @@ -1851,9 +1842,6 @@ impl ScreenLike for DPNSScreen { } } } - - // Save into general error_message for top-of-screen - self.message = Some((message.to_string(), message_type, Utc::now())); } fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { @@ -1895,11 +1883,13 @@ impl ScreenLike for DPNSScreen { )); } + self.vote_banner.take_and_clear(); self.bulk_vote_handling_status = VoteHandlingStatus::Completed; } // If scheduling succeeded BackendTaskSuccessResult::ScheduledVotes => { if self.bulk_vote_handling_status == VoteHandlingStatus::SchedulingVotes { + self.vote_banner.take_and_clear(); self.bulk_vote_handling_status = VoteHandlingStatus::Completed; } self.bulk_schedule_message = @@ -1917,6 +1907,7 @@ impl ScreenLike for DPNSScreen { } BackendTaskSuccessResult::RefreshedDpnsContests | BackendTaskSuccessResult::RefreshedOwnedDpnsNames => { + self.refresh_banner.take_and_clear(); self.refreshing_status = RefreshingStatus::NotRefreshing; } _ => {} @@ -1924,7 +1915,6 @@ impl ScreenLike for DPNSScreen { } fn ui(&mut self, ctx: &Context) -> AppAction { - self.check_error_expiration(); let has_identity_that_can_register = !self.user_identities.is_empty(); let has_active_contests = { let guard = self.contested_names.lock().unwrap(); @@ -2094,44 +2084,8 @@ impl ScreenLike for DPNSScreen { } } - // Show either refreshing indicator or message, but not both - if let RefreshingStatus::Refreshing(start_time) = self.refreshing_status { - ui.add_space(25.0); // Space above - let now = Utc::now().timestamp() as u64; - let elapsed = now - start_time; - ui.horizontal(|ui| { - ui.add_space(10.0); - let dark_mode = ui.ctx().style().visuals.dark_mode; - ui.label( - RichText::new(format!("Refreshing... Time taken so far: {}", elapsed)) - .color(DashColors::text_primary(dark_mode)), - ); - ui.add(egui::widgets::Spinner::default().color(DashColors::DASH_BLUE)); - }); - ui.add_space(2.0); // Space below - } else if let Some((msg, msg_type, timestamp)) = self.message.clone() { - ui.add_space(25.0); // Same space as refreshing indicator - let dark_mode = ui.ctx().style().visuals.dark_mode; - let color = match msg_type { - MessageType::Error => Color32::DARK_RED, - MessageType::Warning => DashColors::warning_color(dark_mode), - MessageType::Info => DashColors::text_primary(dark_mode), - MessageType::Success => Color32::DARK_GREEN, - }; - ui.horizontal(|ui| { - ui.add_space(10.0); - - // Calculate remaining seconds - let now = Utc::now(); - let elapsed = now.signed_duration_since(timestamp); - let remaining = (10 - elapsed.num_seconds()).max(0); - - // Add the message with auto-dismiss countdown - let full_msg = format!("{} ({}s)", msg, remaining); - ui.label(egui::RichText::new(full_msg).color(color)); - }); - ui.add_space(2.0); // Same space below as refreshing indicator - } + // Refreshing indicator is shown via the global banner + // (no inline elapsed rendering needed) inner_action }); @@ -2141,19 +2095,32 @@ impl ScreenLike for DPNSScreen { AppAction::BackendTask(BackendTask::ContestedResourceTask( ContestedResourceTask::QueryDPNSContests, )) => { - self.refreshing_status = - RefreshingStatus::Refreshing(Utc::now().timestamp() as u64); - self.message = None; // Clear any existing message + self.refresh_banner.take_and_clear(); + let handle = MessageBanner::set_global( + ctx, + "Refreshing contested names...", + MessageType::Info, + ); + handle.with_elapsed(); + self.refresh_banner = Some(handle); + self.refreshing_status = RefreshingStatus::Refreshing; } // If refreshing owned names, set self.refreshing = true AppAction::BackendTask(BackendTask::IdentityTask( IdentityTask::RefreshLoadedIdentitiesOwnedDPNSNames, )) => { - self.refreshing_status = - RefreshingStatus::Refreshing(Utc::now().timestamp() as u64); - self.message = None; // Clear any existing message + self.refresh_banner.take_and_clear(); + let handle = MessageBanner::set_global( + ctx, + "Refreshing contested names...", + MessageType::Info, + ); + handle.with_elapsed(); + self.refresh_banner = Some(handle); + self.refreshing_status = RefreshingStatus::Refreshing; } AppAction::SetMainScreen(_) => { + self.refresh_banner.take_and_clear(); self.refreshing_status = RefreshingStatus::NotRefreshing; } _ => {} diff --git a/src/ui/identities/add_existing_identity_screen.rs b/src/ui/identities/add_existing_identity_screen.rs index 88b651007..9da0df439 100644 --- a/src/ui/identities/add_existing_identity_screen.rs +++ b/src/ui/identities/add_existing_identity_screen.rs @@ -11,20 +11,19 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; +use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt}; use crate::ui::theme::DashColors; use crate::ui::{MessageType, ScreenLike}; use bip39::rand::{prelude::IteratorRandom, thread_rng}; use dash_sdk::dashcore_rpc::dashcore::Network; -use dash_sdk::dpp::identity::TimestampMillis; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::platform::Identifier; -use eframe::egui::{Context, Frame, Margin}; +use eframe::egui::Context; use egui::{Color32, ComboBox, RichText, Ui}; use serde::Deserialize; use std::fs; use std::sync::atomic::Ordering; use std::sync::{Arc, RwLock}; -use std::time::{SystemTime, UNIX_EPOCH}; #[derive(Debug, Clone, Deserialize)] struct MasternodeInfo { @@ -86,8 +85,8 @@ enum WalletIdentitySearchMode { #[derive(PartialEq)] pub enum AddIdentityStatus { NotStarted, - WaitingForResult(TimestampMillis), - ErrorMessage(String), + WaitingForResult, + Error, Complete, } @@ -104,23 +103,22 @@ pub struct AddExistingIdentityScreen { selected_wallet: Option>>, identity_associated_with_wallet: bool, wallet_unlock_popup: WalletUnlockPopup, - error_message: Option, pub identity_index_input: String, pub app_context: Arc, show_pop_up_info: Option, mode: LoadIdentityMode, - backend_message: Option, wallet_search_mode: WalletIdentitySearchMode, success_message: Option, dpns_name_input: String, /// Whether to show advanced options show_advanced_options: bool, + refresh_banner: Option, } impl AddExistingIdentityScreen { pub fn new(app_context: &Arc) -> Self { let selected_wallet = app_context.wallets.read().unwrap().values().next().cloned(); - let (testnet_loaded_nodes, error_message) = if app_context.network == Network::Testnet { + let (testnet_loaded_nodes, init_error) = if app_context.network == Network::Testnet { match load_testnet_nodes_from_yml(".testnet_nodes.yml") { Ok(nodes) => (nodes, None), Err(e) => (None, Some(e)), @@ -128,6 +126,9 @@ impl AddExistingIdentityScreen { } else { (None, None) }; + if let Some(err) = init_error { + MessageBanner::set_global(app_context.egui_ctx(), &err, MessageType::Error); + } Self { identity_id_input: String::new(), identity_type: IdentityType::User, @@ -141,16 +142,15 @@ impl AddExistingIdentityScreen { selected_wallet, identity_associated_with_wallet: true, wallet_unlock_popup: WalletUnlockPopup::new(), - error_message, identity_index_input: String::new(), app_context: app_context.clone(), show_pop_up_info: None, mode: LoadIdentityMode::IdentityId, - backend_message: None, wallet_search_mode: WalletIdentitySearchMode::SpecificIndex, success_message: None, dpns_name_input: String::new(), show_advanced_options: false, + refresh_banner: None, } } @@ -271,7 +271,7 @@ impl AddExistingIdentityScreen { if wallet_still_loaded { // Try to open wallet without password if it doesn't use one if let Err(e) = try_open_wallet_no_password(selected_wallet) { - self.error_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } if wallet_needs_unlock(selected_wallet) { @@ -465,11 +465,14 @@ impl AddExistingIdentityScreen { .corner_radius(3.0); if ui.add_enabled(is_valid_id, button).clicked() { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - self.add_identity_status = AddIdentityStatus::WaitingForResult(now); + self.add_identity_status = AddIdentityStatus::WaitingForResult; + let handle = MessageBanner::set_global( + self.app_context.egui_ctx(), + "Loading identity...", + MessageType::Info, + ); + handle.with_elapsed(); + self.refresh_banner = Some(handle); action = self.load_identity_clicked(); } @@ -577,7 +580,7 @@ impl AddExistingIdentityScreen { // Try to open wallet without password if it doesn't use one if let Err(e) = try_open_wallet_no_password(wallet) { - self.error_message = Some(e); + MessageBanner::set_global(self.app_context.egui_ctx(), &e, MessageType::Error); } if wallet_needs_unlock(wallet) { @@ -615,8 +618,6 @@ impl AddExistingIdentityScreen { }); if wallet_mode_changed { self.add_identity_status = AddIdentityStatus::NotStarted; - self.error_message = None; - self.backend_message = None; self.success_message = None; } ui.add_space(6.0); @@ -667,13 +668,15 @@ impl AddExistingIdentityScreen { .corner_radius(3.0); if ui.add(button).clicked() { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - self.add_identity_status = AddIdentityStatus::WaitingForResult(now); - self.backend_message = None; + self.add_identity_status = AddIdentityStatus::WaitingForResult; self.success_message = None; + let handle = MessageBanner::set_global( + self.app_context.egui_ctx(), + "Loading identity...", + MessageType::Info, + ); + handle.with_elapsed(); + self.refresh_banner = Some(handle); // Parse identity index input if let Ok(identity_index) = self.identity_index_input.trim().parse::() { @@ -690,8 +693,12 @@ impl AddExistingIdentityScreen { )); } else { // Handle invalid index input - self.add_identity_status = - AddIdentityStatus::ErrorMessage("Invalid identity index".to_string()); + self.add_identity_status = AddIdentityStatus::NotStarted; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Invalid identity index", + MessageType::Error, + ); } } action @@ -824,13 +831,15 @@ impl AddExistingIdentityScreen { .corner_radius(3.0); if ui.add_enabled(is_valid, button).clicked() { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - self.add_identity_status = AddIdentityStatus::WaitingForResult(now); - self.backend_message = None; + self.add_identity_status = AddIdentityStatus::WaitingForResult; self.success_message = None; + let handle = MessageBanner::set_global( + self.app_context.egui_ctx(), + "Loading identity...", + MessageType::Info, + ); + handle.with_elapsed(); + self.refresh_banner = Some(handle); // Get the selected wallet seed hash for key derivation let selected_wallet_seed_hash = if self.identity_associated_with_wallet { @@ -944,10 +953,8 @@ impl AddExistingIdentityScreen { self.keys_input = vec![String::new(), String::new(), String::new()]; self.identity_index_input.clear(); self.dpns_name_input.clear(); - self.error_message = None; self.show_pop_up_info = None; self.add_identity_status = AddIdentityStatus::NotStarted; - self.backend_message = None; self.success_message = None; return AppAction::None; } @@ -958,21 +965,26 @@ impl AddExistingIdentityScreen { impl ScreenLike for AddExistingIdentityScreen { fn display_message(&mut self, message: &str, message_type: MessageType) { + // Error/success display is handled by the global MessageBanner. + // Side-effects only: update status and progress tracking. match message_type { MessageType::Error => { - self.add_identity_status = AddIdentityStatus::ErrorMessage(message.to_string()); + self.refresh_banner.take_and_clear(); + self.add_identity_status = AddIdentityStatus::Error; } MessageType::Success => { // Check if this is a final success message or a progress update if message.starts_with("Successfully loaded") || message.starts_with("Finished loading") { + self.refresh_banner.take_and_clear(); self.success_message = Some(message.to_string()); self.add_identity_status = AddIdentityStatus::Complete; - self.backend_message = None; } else { - // This is a progress update - self.backend_message = Some(message.to_string()); + // This is a progress update - update the banner text + if let Some(ref handle) = self.refresh_banner { + handle.set_message(message); + } } } _ => {} @@ -982,19 +994,21 @@ impl ScreenLike for AddExistingIdentityScreen { fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { match backend_task_success_result { BackendTaskSuccessResult::LoadedIdentity(_) => { + self.refresh_banner.take_and_clear(); self.success_message = Some("Successfully loaded identity.".to_string()); self.add_identity_status = AddIdentityStatus::Complete; - self.backend_message = None; } BackendTaskSuccessResult::Message(msg) => { // Check if this is a final success message or a progress update if msg.starts_with("Successfully loaded") || msg.starts_with("Finished loading") { + self.refresh_banner.take_and_clear(); self.success_message = Some(msg); self.add_identity_status = AddIdentityStatus::Complete; - self.backend_message = None; } else { - // This is a progress update - self.backend_message = Some(msg); + // This is a progress update - update the banner text + if let Some(ref handle) = self.refresh_banner { + handle.set_message(&msg); + } } } _ => {} @@ -1025,28 +1039,7 @@ impl ScreenLike for AddExistingIdentityScreen { action |= island_central_panel(ctx, |ui| { let mut inner_action = AppAction::None; - // Display error message at the top, outside of scroll area - if let Some(error_message) = self.error_message.clone() { - let error_color = DashColors::ERROR; - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label( - RichText::new(format!("Error: {}", error_message)) - .color(error_color), - ); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.error_message = None; - } - }); - }); - ui.add_space(10.0); - } + // Error display is handled by the global MessageBanner egui::ScrollArea::vertical() .auto_shrink([false; 2]) @@ -1092,8 +1085,6 @@ impl ScreenLike for AddExistingIdentityScreen { if mode_changed { self.add_identity_status = AddIdentityStatus::NotStarted; - self.error_message = None; - self.backend_message = None; self.success_message = None; } @@ -1113,70 +1104,7 @@ impl ScreenLike for AddExistingIdentityScreen { } } - ui.add_space(10.0); - - match &self.add_identity_status { - AddIdentityStatus::NotStarted => { - // Do nothing - } - AddIdentityStatus::WaitingForResult(start_time) => { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let elapsed_seconds = now - start_time; - - let display_time = if elapsed_seconds < 60 { - format!( - "{} second{}", - elapsed_seconds, - if elapsed_seconds == 1 { "" } else { "s" } - ) - } else { - let minutes = elapsed_seconds / 60; - let seconds = elapsed_seconds % 60; - format!( - "{} minute{} and {} second{}", - minutes, - if minutes == 1 { "" } else { "s" }, - seconds, - if seconds == 1 { "" } else { "s" } - ) - }; - - // Show progress message with time, or generic loading message - if let Some(ref progress_msg) = self.backend_message { - ui.label(format!("{} ({})", progress_msg, display_time)); - } else { - ui.label(format!("Loading... ({})", display_time)); - } - } - AddIdentityStatus::ErrorMessage(msg) => { - let error_color = DashColors::ERROR; - let msg = msg.clone(); - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label( - RichText::new(format!("Error: {}", msg)) - .color(error_color), - ); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.add_identity_status = - AddIdentityStatus::NotStarted; - } - }); - }); - } - AddIdentityStatus::Complete => { - // handled above - } - } + // Status display is handled by the global MessageBanner }); inner_action diff --git a/src/ui/identities/add_new_identity_screen/by_platform_address.rs b/src/ui/identities/add_new_identity_screen/by_platform_address.rs index 4984d30df..1be8a1735 100644 --- a/src/ui/identities/add_new_identity_screen/by_platform_address.rs +++ b/src/ui/identities/add_new_identity_screen/by_platform_address.rs @@ -250,14 +250,12 @@ impl AddNewIdentityScreen { .corner_radius(3.0); if ui.add_enabled(can_create, button).clicked() { - self.error_message = None; action = self.register_identity_clicked(FundingMethod::UsePlatformAddress); } ui.add_space(20.0); - // Only show status messages if there's no error - if self.error_message.is_none() { + { ui.vertical_centered(|ui| match step { WalletFundedScreenStep::WaitingForPlatformAcceptance => { ui.heading("=> Waiting for Platform acknowledgement <="); diff --git a/src/ui/identities/add_new_identity_screen/by_using_unused_asset_lock.rs b/src/ui/identities/add_new_identity_screen/by_using_unused_asset_lock.rs index c79213538..68d848ffa 100644 --- a/src/ui/identities/add_new_identity_screen/by_using_unused_asset_lock.rs +++ b/src/ui/identities/add_new_identity_screen/by_using_unused_asset_lock.rs @@ -128,14 +128,12 @@ impl AddNewIdentityScreen { ui.add_space(10.0); if ui.button("Create Identity").clicked() { - self.error_message = None; action |= self.register_identity_clicked(FundingMethod::UseUnusedAssetLock); } ui.add_space(20.0); - // Only show status messages if there's no error - if self.error_message.is_none() { + { ui.vertical_centered(|ui| match step { WalletFundedScreenStep::WaitingForPlatformAcceptance => { ui.heading("=> Waiting for Platform acknowledgement <="); diff --git a/src/ui/identities/add_new_identity_screen/by_using_unused_balance.rs b/src/ui/identities/add_new_identity_screen/by_using_unused_balance.rs index abdbba791..45abb21d9 100644 --- a/src/ui/identities/add_new_identity_screen/by_using_unused_balance.rs +++ b/src/ui/identities/add_new_identity_screen/by_using_unused_balance.rs @@ -88,14 +88,12 @@ impl AddNewIdentityScreen { .frame(true) .corner_radius(3.0); if ui.add(button).clicked() { - self.error_message = None; action = self.register_identity_clicked(FundingMethod::UseWalletBalance); } ui.add_space(20.0); - // Only show status messages if there's no error - if self.error_message.is_none() { + { ui.vertical_centered(|ui| match step { WalletFundedScreenStep::WaitingForAssetLock => { ui.heading( diff --git a/src/ui/identities/add_new_identity_screen/by_wallet_qr_code.rs b/src/ui/identities/add_new_identity_screen/by_wallet_qr_code.rs index 5c8b06a9a..d6b593760 100644 --- a/src/ui/identities/add_new_identity_screen/by_wallet_qr_code.rs +++ b/src/ui/identities/add_new_identity_screen/by_wallet_qr_code.rs @@ -3,6 +3,8 @@ use crate::backend_task::BackendTask; use crate::backend_task::identity::{ IdentityRegistrationInfo, IdentityTask, RegisterIdentityFundingMethod, }; +use crate::ui::MessageType; +use crate::ui::components::MessageBanner; use crate::ui::identities::add_new_identity_screen::{ AddNewIdentityScreen, WalletFundedScreenStep, }; @@ -165,7 +167,7 @@ impl AddNewIdentityScreen { egui::Layout::top_down(egui::Align::Min).with_cross_align(egui::Align::Center), |ui| { if let Err(e) = self.render_qr_code(ui, amount_dash) { - self.error_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } ui.add_space(20.0); @@ -199,8 +201,7 @@ impl AddNewIdentityScreen { } } - // Only show status messages if there's no error - if self.error_message.is_none() { + { match step { WalletFundedScreenStep::WaitingOnFunds => { ui.heading("=> Waiting for funds. <="); diff --git a/src/ui/identities/add_new_identity_screen/mod.rs b/src/ui/identities/add_new_identity_screen/mod.rs index 1efc1c4b2..80479aab6 100644 --- a/src/ui/identities/add_new_identity_screen/mod.rs +++ b/src/ui/identities/add_new_identity_screen/mod.rs @@ -85,9 +85,6 @@ pub struct AddNewIdentityScreen { alias_input: String, copied_to_clipboard: Option>, identity_keys: IdentityKeys, - error_message: Option, - /// Tracks the last error pushed to the global banner to avoid re-sending each frame. - last_global_error: Option, wallet_unlock_popup: WalletUnlockPopup, show_pop_up_info: Option, in_key_selection_advanced_mode: bool, @@ -153,8 +150,6 @@ impl AddNewIdentityScreen { master_private_key_type: KeyType::ECDSA_HASH160, keys_input: vec![], }, - error_message: None, - last_global_error: None, wallet_unlock_popup: WalletUnlockPopup::new(), show_pop_up_info: None, in_key_selection_advanced_mode: false, @@ -865,12 +860,20 @@ impl AddNewIdentityScreen { // Get selected Platform address and amount from the input fields let Some((platform_addr, amount)) = self.selected_platform_address_for_funding else { - self.error_message = Some("Please select a Platform address".to_string()); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Please select a Platform address", + MessageType::Error, + ); return AppAction::None; }; if amount == 0 { - self.error_message = Some("Amount must be greater than 0".to_string()); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Amount must be greater than 0", + MessageType::Error, + ); return AppAction::None; } @@ -1018,7 +1021,7 @@ impl AddNewIdentityScreen { impl ScreenLike for AddNewIdentityScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { - if message_type == MessageType::Error { + if matches!(message_type, MessageType::Error | MessageType::Warning) { // Reset step so we stop showing "Waiting for Platform acknowledgement". // The error itself is displayed by the global MessageBanner. let mut step = self.step.write().unwrap(); @@ -1103,18 +1106,6 @@ impl ScreenLike for AddNewIdentityScreen { action |= island_central_panel(ctx, |ui| { let mut inner_action = AppAction::None; - // Display local validation errors via the global MessageBanner. - // Only push when the message changes to avoid resetting the banner each frame - // (e.g. try_open_wallet_no_password can re-set error_message every render pass). - if self.error_message != self.last_global_error { - if let Some(error_message) = self.error_message.as_ref() { - MessageBanner::set_global(ui.ctx(), error_message, MessageType::Error); - } else if let Some(old) = self.last_global_error.as_ref() { - MessageBanner::clear_global_message(ui.ctx(), old); - } - self.last_global_error = self.error_message.clone(); - } - ScrollArea::vertical().show(ui, |ui| { let step = {*self.step.read().unwrap()}; if step == WalletFundedScreenStep::Success { @@ -1148,7 +1139,7 @@ impl ScreenLike for AddNewIdentityScreen { // Try to open wallet without password if it doesn't use one if let Err(e) = try_open_wallet_no_password(wallet) { - self.error_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } // If wallet needs password unlock diff --git a/src/ui/identities/identities_screen.rs b/src/ui/identities/identities_screen.rs index fa4501aa3..23bfc5af5 100644 --- a/src/ui/identities/identities_screen.rs +++ b/src/ui/identities/identities_screen.rs @@ -11,7 +11,7 @@ use crate::model::wallet::WalletSeedHash; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::top_panel::add_top_panel; -use crate::ui::components::{BannerHandle, MessageBanner}; +use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt}; use crate::ui::identities::keys::add_key_screen::AddKeyScreen; use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; use crate::ui::identities::register_dpns_name_screen::{ @@ -942,7 +942,7 @@ impl IdentitiesScreen { ); MessageBanner::set_global( self.app_context.egui_ctx(), - &format!("Failed to remove identity: {}", e), + format!("Failed to remove identity: {}", e), MessageType::Error, ); } @@ -1109,10 +1109,8 @@ impl ScreenLike for IdentitiesScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. - if matches!(message_type, MessageType::Error | MessageType::Warning) - && let Some(handle) = self.refresh_banner.take() - { - handle.clear(); + if matches!(message_type, MessageType::Error | MessageType::Warning) { + self.refresh_banner.take_and_clear(); } } @@ -1125,9 +1123,7 @@ impl ScreenLike for IdentitiesScreen { { self.pending_refresh_count = self.pending_refresh_count.saturating_sub(1); if self.pending_refresh_count == 0 { - if let Some(handle) = self.refresh_banner.take() { - handle.clear(); - } + self.refresh_banner.take_and_clear(); let message = if self.total_refresh_count == 1 { "Successfully refreshed identity".to_string() } else { @@ -1229,9 +1225,7 @@ impl ScreenLike for IdentitiesScreen { ) }) => { - if let Some(handle) = self.refresh_banner.take() { - handle.clear(); - } + self.refresh_banner.take_and_clear(); self.pending_refresh_count = tasks.len(); self.total_refresh_count = tasks.len(); let handle = diff --git a/src/ui/identities/keys/add_key_screen.rs b/src/ui/identities/keys/add_key_screen.rs index 970a9a3cd..ea24319c7 100644 --- a/src/ui/identities/keys/add_key_screen.rs +++ b/src/ui/identities/keys/add_key_screen.rs @@ -12,6 +12,7 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; +use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt, ResultBannerExt}; use crate::ui::identities::get_selected_wallet; use crate::ui::theme::DashColors; use crate::ui::{MessageType, ScreenLike}; @@ -24,18 +25,16 @@ use dash_sdk::dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::dpp::prelude::Identifier; -use dash_sdk::dpp::prelude::TimestampMillis; use eframe::egui::{self, Context, Frame, Margin}; use egui::{Color32, RichText, Ui}; use std::collections::HashSet; use std::sync::{Arc, RwLock}; -use std::time::{SystemTime, UNIX_EPOCH}; #[derive(PartialEq)] pub enum AddKeyStatus { NotStarted, - WaitingForResult(TimestampMillis), - ErrorMessage(String), + WaitingForResult, + Error, Complete, } @@ -49,12 +48,12 @@ pub struct AddKeyScreen { add_key_status: AddKeyStatus, selected_wallet: Option>>, wallet_unlock_popup: WalletUnlockPopup, - error_message: Option, contract_id_input: String, document_type_input: String, enable_contract_bounds: bool, // Fee result from completed operation completed_fee_result: Option, + refresh_banner: Option, } impl AddKeyScreen { @@ -66,9 +65,9 @@ impl AddKeyScreen { KeyType::all_key_types().into(), false, ); - let mut error_message = None; - let selected_wallet = - get_selected_wallet(&identity, None, selected_key, &mut error_message); + let selected_wallet = get_selected_wallet(&identity, None, selected_key) + .or_show_error(app_context.egui_ctx()) + .unwrap_or(None); Self { identity, @@ -80,11 +79,11 @@ impl AddKeyScreen { add_key_status: AddKeyStatus::NotStarted, selected_wallet, wallet_unlock_popup: WalletUnlockPopup::new(), - error_message, contract_id_input: String::new(), document_type_input: String::new(), enable_contract_bounds: false, completed_fee_result: None, + refresh_banner: None, } } @@ -101,9 +100,9 @@ impl AddKeyScreen { KeyType::all_key_types().into(), false, ); - let mut error_message = None; - let selected_wallet = - get_selected_wallet(&identity, None, selected_key, &mut error_message); + let selected_wallet = get_selected_wallet(&identity, None, selected_key) + .or_show_error(app_context.egui_ctx()) + .unwrap_or(None); let dashpay_contract_id = app_context .dashpay_contract @@ -120,11 +119,11 @@ impl AddKeyScreen { add_key_status: AddKeyStatus::NotStarted, selected_wallet, wallet_unlock_popup: WalletUnlockPopup::new(), - error_message, contract_id_input: dashpay_contract_id, document_type_input: String::new(), enable_contract_bounds: true, completed_fee_result: None, + refresh_banner: None, } } @@ -141,9 +140,9 @@ impl AddKeyScreen { KeyType::all_key_types().into(), false, ); - let mut error_message = None; - let selected_wallet = - get_selected_wallet(&identity, None, selected_key, &mut error_message); + let selected_wallet = get_selected_wallet(&identity, None, selected_key) + .or_show_error(app_context.egui_ctx()) + .unwrap_or(None); let dashpay_contract_id = app_context .dashpay_contract @@ -160,11 +159,11 @@ impl AddKeyScreen { add_key_status: AddKeyStatus::NotStarted, selected_wallet, wallet_unlock_popup: WalletUnlockPopup::new(), - error_message, contract_id_input: dashpay_contract_id, document_type_input: String::new(), enable_contract_bounds: true, completed_fee_result: None, + refresh_banner: None, } } @@ -179,8 +178,12 @@ impl AddKeyScreen { self.app_context.network, ); if let Err(err) = public_key_data_result { - self.add_key_status = - AddKeyStatus::ErrorMessage(format!("Issue verifying private key: {}", err)); + self.add_key_status = AddKeyStatus::Error; + MessageBanner::set_global( + self.app_context.egui_ctx(), + format!("Issue verifying private key: {}", err), + MessageType::Error, + ); } else { // Handle contract bounds if enabled let contract_bounds = if self.enable_contract_bounds @@ -198,10 +201,12 @@ impl AddKeyScreen { } } Err(e) => { - self.add_key_status = AddKeyStatus::ErrorMessage(format!( - "Invalid contract ID: {}", - e - )); + self.add_key_status = AddKeyStatus::Error; + MessageBanner::set_global( + self.app_context.egui_ctx(), + format!("Invalid contract ID: {}", e), + MessageType::Error, + ); return app_action; } } @@ -224,10 +229,12 @@ impl AddKeyScreen { let validation_result = new_key .validate_private_key_bytes(&private_key_bytes, self.app_context.network); if let Err(err) = validation_result { - self.add_key_status = AddKeyStatus::ErrorMessage(format!( - "Issue verifying private key: {}", - err - )); + self.add_key_status = AddKeyStatus::Error; + MessageBanner::set_global( + self.app_context.egui_ctx(), + format!("Issue verifying private key: {}", err), + MessageType::Error, + ); } else if validation_result.unwrap() { let new_qualified_key = QualifiedIdentityPublicKey { identity_public_key: new_key.into(), @@ -241,19 +248,30 @@ impl AddKeyScreen { ), )); } else { - self.add_key_status = AddKeyStatus::ErrorMessage( - "Private key does not match the public key.".to_string(), + self.add_key_status = AddKeyStatus::Error; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Private key does not match the public key.", + MessageType::Error, ); } } } Ok(_) => { - self.add_key_status = - AddKeyStatus::ErrorMessage("Private key not 32 bytes".to_string()); + self.add_key_status = AddKeyStatus::Error; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Private key not 32 bytes", + MessageType::Error, + ); } Err(_) => { - self.add_key_status = - AddKeyStatus::ErrorMessage("Invalid hex string for private key.".to_string()); + self.add_key_status = AddKeyStatus::Error; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Invalid hex string for private key.", + MessageType::Error, + ); } } app_action @@ -270,8 +288,12 @@ impl AddKeyScreen { { self.private_key_input = hex::encode(private_key_bytes); } else { - self.add_key_status = - AddKeyStatus::ErrorMessage("Failed to generate a random private key.".to_string()); + self.add_key_status = AddKeyStatus::Error; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Failed to generate a random private key.", + MessageType::Error, + ); } } @@ -313,10 +335,12 @@ impl AddKeyScreen { impl ScreenLike for AddKeyScreen { fn refresh(&mut self) { - if let Some(refreshed_identity) = self + let identities = self .app_context .load_local_user_identities() - .expect("Expected to load local identities") + .or_show_error(self.app_context.egui_ctx()) + .unwrap_or_default(); + if let Some(refreshed_identity) = identities .iter() .find(|identity| identity.identity.id() == self.identity.identity.id()) { @@ -324,15 +348,18 @@ impl ScreenLike for AddKeyScreen { } } - fn display_message(&mut self, message: &str, message_type: MessageType) { - if let MessageType::Error = message_type { - self.add_key_status = AddKeyStatus::ErrorMessage(message.to_string()); + fn display_message(&mut self, _message: &str, message_type: MessageType) { + // Error/success display is handled by the global MessageBanner. + if matches!(message_type, MessageType::Error | MessageType::Warning) { + self.refresh_banner.take_and_clear(); + self.add_key_status = AddKeyStatus::Error; } } fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { match backend_task_success_result { BackendTaskSuccessResult::AddedKeyToIdentity(fee_result) => { + self.refresh_banner.take_and_clear(); self.completed_fee_result = Some(fee_result); self.add_key_status = AddKeyStatus::Complete; } @@ -372,16 +399,11 @@ impl ScreenLike for AddKeyScreen { ui.heading("Add New Key"); ui.add_space(10.0); - if self.add_key_status == AddKeyStatus::Complete { - inner_action |= self.show_success(ui); - return inner_action; - } - if self.selected_wallet.is_some() && let Some(wallet) = &self.selected_wallet { if let Err(e) = try_open_wallet_no_password(wallet) { - self.error_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } if wallet_needs_unlock(wallet) { ui.add_space(10.0); @@ -641,71 +663,17 @@ impl ScreenLike for AddKeyScreen { .frame(true) .corner_radius(3.0); if ui.add(button).clicked() { - // Set the status to waiting and capture the current time - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - self.add_key_status = AddKeyStatus::WaitingForResult(now); - inner_action |= self.validate_and_add_key(); - } - ui.add_space(10.0); - - match &self.add_key_status { - AddKeyStatus::NotStarted => { - // Do nothing - } - AddKeyStatus::WaitingForResult(start_time) => { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let elapsed_seconds = now - start_time; - - let display_time = if elapsed_seconds < 60 { - format!( - "{} second{}", - elapsed_seconds, - if elapsed_seconds == 1 { "" } else { "s" } - ) - } else { - let minutes = elapsed_seconds / 60; - let seconds = elapsed_seconds % 60; - format!( - "{} minute{} and {} second{}", - minutes, - if minutes == 1 { "" } else { "s" }, - seconds, - if seconds == 1 { "" } else { "s" } - ) - }; - - ui.label(format!("Adding key... Time taken so far: {}", display_time)); - } - AddKeyStatus::ErrorMessage(msg) => { - let error_color = DashColors::ERROR; - let msg = msg.clone(); - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label( - RichText::new(format!("Error: {}", msg)).color(error_color), - ); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.add_key_status = AddKeyStatus::NotStarted; - } - }); - }); - } - AddKeyStatus::Complete => { - // handled above + let validation_action = self.validate_and_add_key(); + if matches!(&validation_action, AppAction::BackendTask(_)) { + self.add_key_status = AddKeyStatus::WaitingForResult; + let handle = + MessageBanner::set_global(ui.ctx(), "Adding key...", MessageType::Info); + handle.with_elapsed(); + self.refresh_banner = Some(handle); } + inner_action |= validation_action; } + // Status display is handled by the global MessageBanner inner_action }); diff --git a/src/ui/identities/keys/key_info_screen.rs b/src/ui/identities/keys/key_info_screen.rs index 1917ab876..0a742ca81 100644 --- a/src/ui/identities/keys/key_info_screen.rs +++ b/src/ui/identities/keys/key_info_screen.rs @@ -5,7 +5,7 @@ use crate::model::qualified_identity::encrypted_key_storage::{ PrivateKeyData, WalletDerivationPath, }; use crate::model::wallet::Wallet; -use crate::ui::ScreenLike; +use crate::ui::components::MessageBanner; use crate::ui::components::info_popup::InfoPopup; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; @@ -14,6 +14,7 @@ use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; use crate::ui::theme::DashColors; +use crate::ui::{MessageType, ScreenLike}; use base64::Engine; use base64::engine::general_purpose::STANDARD; use dash_sdk::dashcore_rpc::dashcore::PrivateKey as RPCPrivateKey; @@ -30,7 +31,7 @@ use dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBound use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::platform::IdentityPublicKey; use eframe::egui::{self, Context}; -use egui::{Color32, Frame, Margin, RichText, ScrollArea}; +use egui::{Color32, RichText, ScrollArea}; use std::sync::{Arc, RwLock}; pub struct KeyInfoScreen { @@ -40,12 +41,10 @@ pub struct KeyInfoScreen { pub decrypted_private_key: Option, pub app_context: Arc, private_key_input: String, - error_message: Option, selected_wallet: Option>>, wallet_unlock_popup: WalletUnlockPopup, message_input: String, signed_message: Option, - sign_error_message: Option, view_wallet_unlock: bool, wallet_open: bool, view_private_key_even_if_encrypted_or_in_wallet: bool, @@ -504,35 +503,14 @@ impl ScreenLike for KeyInfoScreen { if ui.button("Add Private Key").clicked() { self.validate_and_store_private_key(); } - - // Display error message if validation fails - if let Some(error_message) = self.error_message.clone() { - let error_color = DashColors::ERROR; - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label( - RichText::new(format!("Error: {}", error_message)) - .color(error_color), - ); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.error_message = None; - } - }); - }); - } + // Error display is handled by the global MessageBanner } if self.view_wallet_unlock && let Some(wallet) = &self.selected_wallet { if let Err(e) = try_open_wallet_no_password(wallet) { - self.error_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } if wallet_needs_unlock(wallet) { ui.add_space(10.0); @@ -611,12 +589,10 @@ impl KeyInfoScreen { decrypted_private_key: None, app_context: app_context.clone(), private_key_input: String::new(), - error_message: None, selected_wallet, wallet_unlock_popup: WalletUnlockPopup::new(), message_input: "".to_string(), signed_message: None, - sign_error_message: None, view_wallet_unlock: false, wallet_open: false, view_private_key_even_if_encrypted_or_in_wallet: false, @@ -632,14 +608,21 @@ impl KeyInfoScreen { private_key_bytes_vec.try_into().unwrap() } Ok(_) => { - self.error_message = Some("Private key not 32 bytes".to_string()); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Private key not 32 bytes", + MessageType::Error, + ); return; } Err(_) => match PrivateKey::from_wif(&self.private_key_input) { Ok(key) => key.inner.secret_bytes(), Err(_) => { - self.error_message = - Some("Invalid hex string or WIF for private key.".to_string()); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Invalid hex string or WIF for private key.", + MessageType::Error, + ); return; } }, @@ -649,7 +632,11 @@ impl KeyInfoScreen { .key .validate_private_key_bytes(&private_key_bytes, self.app_context.network); if let Err(err) = validation_result { - self.error_message = Some(format!("Issue verifying private key {}", err)); + MessageBanner::set_global( + self.app_context.egui_ctx(), + format!("Issue verifying private key {}", err), + MessageType::Error, + ); } else if validation_result.unwrap() { // If valid, store the private key in the context and reset the input field self.private_key_data = Some((PrivateKeyData::Clear(private_key_bytes), None)); @@ -657,19 +644,22 @@ impl KeyInfoScreen { (self.key.purpose().into(), self.key.id()), (self.key.clone().into(), private_key_bytes), ); - match self + if let Err(e) = self .app_context .update_local_qualified_identity(&self.identity) { - Ok(_) => { - self.error_message = None; - } - Err(e) => { - self.error_message = Some(format!("Issue saving: {}", e)); - } + MessageBanner::set_global( + self.app_context.egui_ctx(), + format!("Issue saving: {}", e), + MessageType::Error, + ); } } else { - self.error_message = Some("Private key does not match the public key.".to_string()); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Private key does not match the public key.", + MessageType::Error, + ); } } @@ -706,25 +696,7 @@ impl KeyInfoScreen { self.sign_message(); } - if let Some(error_message) = self.sign_error_message.clone() { - let error_color = DashColors::ERROR; - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label( - RichText::new(format!("Error: {}", error_message)).color(error_color), - ); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.sign_error_message = None; - } - }); - }); - } + // Sign error display is handled by the global MessageBanner if let Some(signed_message) = &self.signed_message { ui.add_space(10.0); @@ -751,7 +723,11 @@ impl KeyInfoScreen { (_, Some(private_key)) => private_key.inner.secret_bytes(), // Other cases may not have the private key directly _ => { - self.sign_error_message = Some("Private key is not available.".to_string()); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Private key is not available.", + MessageType::Error, + ); return; } }; @@ -777,14 +753,21 @@ impl KeyInfoScreen { let signature_base64 = STANDARD.encode(serialized_signature); self.signed_message = Some(signature_base64); - self.sign_error_message = None; } _ => { - self.sign_error_message = Some("Unsupported key type for signing.".to_string()); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Unsupported key type for signing.", + MessageType::Error, + ); } } } else { - self.sign_error_message = Some("Private key is not available.".to_string()); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Private key is not available.", + MessageType::Error, + ); } } @@ -811,16 +794,15 @@ impl KeyInfoScreen { .private_keys .private_keys .remove(&(self.key.purpose().into(), self.key.id())); - match self + if let Err(e) = self .app_context .update_local_qualified_identity(&self.identity) { - Ok(_) => { - self.error_message = None; - } - Err(e) => { - self.error_message = Some(format!("Issue saving: {}", e)); - } + MessageBanner::set_global( + ui.ctx(), + format!("Issue saving: {}", e), + MessageType::Error, + ); } self.show_confirm_remove_private_key = false; } diff --git a/src/ui/identities/mod.rs b/src/ui/identities/mod.rs index eeec7b7b3..d582aa176 100644 --- a/src/ui/identities/mod.rs +++ b/src/ui/identities/mod.rs @@ -48,61 +48,42 @@ pub mod withdraw_screen; /// DPNS contract. When present, DPNS logic is used to find the public key. /// - `selected_key`: An optional reference to a chosen [`IdentityPublicKey`]. /// When `app_context` is not provided, this is required to get the wallet. -/// - `error_message`: A mutable optional string where any error message will -/// be written if the function fails to retrieve a wallet. /// /// # Returns /// -/// Returns `Some(Arc>)` if a matching wallet is found, or `None` -/// otherwise. If an error is encountered, an explanatory message is placed in -/// `error_message`. +/// Returns `Ok(Some(Arc>))` if a matching wallet is found, +/// `Ok(None)` if no wallet is associated with the key, or `Err(String)` if +/// an error is encountered. /// /// # Errors /// /// - If the DPNS document type can't be found or the identity is missing the /// required DPNS signing key (when `app_context` is provided). /// - If no `selected_key` is provided (when `app_context` is `None`). -/// - If the derived wallet derivation path is missing from the -/// [`QualifiedIdentity`]. pub fn get_selected_wallet( qualified_identity: &QualifiedIdentity, - app_context: Option<&AppContext>, // Used for DPNS-based logic (the first scenario). - selected_key: Option<&IdentityPublicKey>, // Used for direct-key logic (the fallback scenario). - error_message: &mut Option, -) -> Option>> { + app_context: Option<&AppContext>, + selected_key: Option<&IdentityPublicKey>, +) -> Result>>, String> { // If `app_context` is provided, use the DPNS-based approach. let public_key = if let Some(context) = app_context { let dpns_contract = &context.dpns_contract; // Attempt to fetch the `preorder` document type from the DPNS contract. - let preorder_document_type = match dpns_contract.document_type_for_name("preorder") { - Ok(doc_type) => doc_type, - Err(e) => { - *error_message = Some(format!("DPNS preorder document type not found: {}", e)); - return None; - } - }; + let preorder_document_type = dpns_contract + .document_type_for_name("preorder") + .map_err(|e| format!("DPNS preorder document type not found: {}", e))?; // Attempt to retrieve the public key from the identity. - match qualified_identity.document_signing_key(&preorder_document_type) { - Some(key) => key, - None => { - *error_message = Some( - "Identity doesn't have an authentication key for signing document transitions" - .to_string(), - ); - return None; - } - } + qualified_identity + .document_signing_key(&preorder_document_type) + .ok_or_else(|| { + "Identity doesn't have an authentication key for signing document transitions" + .to_string() + })? } else { // Fallback: directly use the provided selected key. - match selected_key { - Some(key) => key, - None => { - *error_message = Some("No key provided when getting selected wallet".to_string()); - return None; - } - } + selected_key.ok_or_else(|| "No key provided when getting selected wallet".to_string())? }; // Once we have the public key (either from DPNS or directly), look up @@ -115,11 +96,11 @@ pub fn get_selected_wallet( .get(&key_lookup) { // If found, return the associated wallet (cloned to preserve Arc). - qualified_identity + Ok(qualified_identity .associated_wallets .get(&wallet_derivation_path.wallet_seed_hash) - .cloned() + .cloned()) } else { - None + Ok(None) } } diff --git a/src/ui/identities/register_dpns_name_screen.rs b/src/ui/identities/register_dpns_name_screen.rs index 7ceb12809..28a798ad9 100644 --- a/src/ui/identities/register_dpns_name_screen.rs +++ b/src/ui/identities/register_dpns_name_screen.rs @@ -12,19 +12,19 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; +use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt, ResultBannerExt}; use crate::ui::helpers::{TransactionType, add_key_chooser_with_doc_type}; use crate::ui::theme::DashColors; use crate::ui::{MessageType, ScreenLike}; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; +use dash_sdk::dpp::identity::Purpose; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; -use dash_sdk::dpp::identity::{Purpose, TimestampMillis}; use dash_sdk::platform::{Identifier, IdentityPublicKey}; use eframe::egui::{Context, Frame, Margin}; use egui::{Color32, RichText, Ui}; use std::sync::Arc; use std::sync::RwLock; -use std::time::{SystemTime, UNIX_EPOCH}; use super::get_selected_wallet; @@ -39,8 +39,8 @@ pub enum RegisterDpnsNameSource { #[derive(PartialEq)] pub enum RegisterDpnsNameStatus { NotStarted, - WaitingForResult(TimestampMillis), - ErrorMessage(String), + WaitingForResult, + Error, Complete, } @@ -55,12 +55,12 @@ pub struct RegisterDpnsNameScreen { pub app_context: Arc, selected_wallet: Option>>, wallet_unlock_popup: WalletUnlockPopup, - error_message: Option, show_advanced_options: bool, // Fee result from completed operation completed_fee_result: Option, // Source of navigation to this screen pub source: RegisterDpnsNameSource, + refresh_banner: Option, } impl RegisterDpnsNameScreen { @@ -69,9 +69,10 @@ impl RegisterDpnsNameScreen { app_context.load_local_user_identities().unwrap_or_default(); let selected_qualified_identity = qualified_identities.first().cloned(); - let mut error_message: Option = None; let selected_wallet = if let Some(ref identity) = selected_qualified_identity { - get_selected_wallet(identity, Some(app_context), None, &mut error_message) + get_selected_wallet(identity, Some(app_context), None) + .or_show_error(app_context.egui_ctx()) + .unwrap_or(None) } else { None }; @@ -118,10 +119,10 @@ impl RegisterDpnsNameScreen { app_context: app_context.clone(), selected_wallet, wallet_unlock_popup: WalletUnlockPopup::new(), - error_message, show_advanced_options: false, completed_fee_result: None, source, + refresh_banner: None, } } @@ -159,8 +160,9 @@ impl RegisterDpnsNameScreen { .cloned(); // Update the selected wallet - self.selected_wallet = - get_selected_wallet(qi, Some(&self.app_context), None, &mut self.error_message); + self.selected_wallet = get_selected_wallet(qi, Some(&self.app_context), None) + .or_show_error(self.app_context.egui_ctx()) + .unwrap_or(None); } else { // If not found, you might want to handle this case // For now, we'll set selected_qualified_identity to None @@ -211,12 +213,9 @@ impl RegisterDpnsNameScreen { .cloned(); // Update wallet - self.selected_wallet = get_selected_wallet( - identity, - Some(&self.app_context), - None, - &mut self.error_message, - ); + self.selected_wallet = get_selected_wallet(identity, Some(&self.app_context), None) + .or_show_error(self.app_context.egui_ctx()) + .unwrap_or(None); } else { self.selected_key = None; self.selected_wallet = None; @@ -294,10 +293,11 @@ impl RegisterDpnsNameScreen { } impl ScreenLike for RegisterDpnsNameScreen { - fn display_message(&mut self, message: &str, message_type: MessageType) { - if let MessageType::Error = message_type { - self.register_dpns_name_status = - RegisterDpnsNameStatus::ErrorMessage(message.to_string()); + fn display_message(&mut self, _message: &str, message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. + if matches!(message_type, MessageType::Error | MessageType::Warning) { + self.refresh_banner.take_and_clear(); + self.register_dpns_name_status = RegisterDpnsNameStatus::Error; } } @@ -305,6 +305,7 @@ impl ScreenLike for RegisterDpnsNameScreen { if let BackendTaskSuccessResult::RegisteredDpnsName(fee_result) = backend_task_success_result { + self.refresh_banner.take_and_clear(); self.completed_fee_result = Some(fee_result); self.register_dpns_name_status = RegisterDpnsNameStatus::Complete; } @@ -403,7 +404,7 @@ impl ScreenLike for RegisterDpnsNameScreen { if self.selected_wallet.is_some() && let Some(wallet) = &self.selected_wallet { if let Err(e) = try_open_wallet_no_password(wallet) { - self.error_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } if wallet_needs_unlock(wallet) { ui.add_space(10.0); @@ -542,73 +543,13 @@ impl ScreenLike for RegisterDpnsNameScreen { .on_disabled_hover_text(&hover_text) .clicked() { - // Set the status to waiting and capture the current time - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - self.register_dpns_name_status = RegisterDpnsNameStatus::WaitingForResult(now); + self.register_dpns_name_status = RegisterDpnsNameStatus::WaitingForResult; + let handle = MessageBanner::set_global(ui.ctx(), "Registering DPNS name...", MessageType::Info); + handle.with_elapsed(); + self.refresh_banner = Some(handle); inner_action = self.register_dpns_name_clicked(); } - ui.add_space(10.0); - - // Handle registration status messages - match &self.register_dpns_name_status { - RegisterDpnsNameStatus::NotStarted => { - // Do nothing - } - RegisterDpnsNameStatus::WaitingForResult(start_time) => { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let elapsed_seconds = now - start_time; - - let display_time = if elapsed_seconds < 60 { - format!( - "{} second{}", - elapsed_seconds, - if elapsed_seconds == 1 { "" } else { "s" } - ) - } else { - let minutes = elapsed_seconds / 60; - let seconds = elapsed_seconds % 60; - format!( - "{} minute{} and {} second{}", - minutes, - if minutes == 1 { "" } else { "s" }, - seconds, - if seconds == 1 { "" } else { "s" } - ) - }; - - ui.label(format!( - "Registering... Time taken so far: {}", - display_time - )); - } - RegisterDpnsNameStatus::ErrorMessage(msg) => { - let error_color = DashColors::ERROR; - let msg = msg.clone(); - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label(RichText::new(format!("Error: {}", msg)).color(error_color)); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.register_dpns_name_status = RegisterDpnsNameStatus::NotStarted; - } - }); - }); - } - RegisterDpnsNameStatus::Complete => {} - } - ui.add_space(10.0); ui.separator(); ui.add_space(10.0); diff --git a/src/ui/identities/transfer_screen.rs b/src/ui/identities/transfer_screen.rs index 4a45c1713..a2247b8a0 100644 --- a/src/ui/identities/transfer_screen.rs +++ b/src/ui/identities/transfer_screen.rs @@ -13,6 +13,7 @@ use crate::ui::components::identity_selector::IdentitySelector; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::top_panel::add_top_panel; +use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt, ResultBannerExt}; use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; use crate::ui::{MessageType, Screen, ScreenLike}; use dash_sdk::dashcore_rpc::dashcore::Address; @@ -23,13 +24,11 @@ use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; use dash_sdk::dpp::platform_value::string_encoding::Encoding; -use dash_sdk::dpp::prelude::TimestampMillis; use dash_sdk::platform::{Identifier, IdentityPublicKey}; use eframe::egui::{self, Context, Frame, Margin, Ui}; use egui::{Color32, RichText}; use std::collections::BTreeMap; use std::sync::{Arc, RwLock}; -use std::time::{SystemTime, UNIX_EPOCH}; use super::get_selected_wallet; use super::keys::add_key_screen::AddKeyScreen; @@ -50,8 +49,8 @@ pub enum TransferDestinationType { #[derive(PartialEq)] pub enum TransferCreditsStatus { NotStarted, - WaitingForResult(TimestampMillis), - ErrorMessage(String), + WaitingForResult, + Error, Complete, } @@ -63,7 +62,6 @@ pub struct TransferScreen { amount: Option, amount_input: Option, transfer_credits_status: TransferCreditsStatus, - error_message: Option, max_amount: u64, pub app_context: Arc, confirmation_popup: bool, @@ -76,13 +74,15 @@ pub struct TransferScreen { show_advanced_options: bool, // Fee result from completed operation completed_fee_result: Option, + refresh_banner: Option, } impl TransferScreen { pub fn new(identity: QualifiedIdentity, app_context: &Arc) -> Self { let known_identities = app_context .load_local_qualified_identities() - .expect("Identities not loaded"); + .or_show_error(app_context.egui_ctx()) + .unwrap_or_default(); let max_amount = identity.identity.balance(); let identity_clone = identity.identity.clone(); @@ -92,9 +92,11 @@ impl TransferScreen { KeyType::all_key_types().into(), false, ); - let mut error_message = None; let selected_wallet = - get_selected_wallet(&identity, None, selected_key, &mut error_message); + get_selected_wallet(&identity, None, selected_key).unwrap_or_else(|e| { + MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error); + None + }); Self { identity, selected_key: selected_key.cloned(), @@ -103,7 +105,6 @@ impl TransferScreen { amount: Some(Amount::new_dash(0.0)), amount_input: None, transfer_credits_status: TransferCreditsStatus::NotStarted, - error_message: None, max_amount, app_context: app_context.clone(), confirmation_popup: false, @@ -114,6 +115,7 @@ impl TransferScreen { platform_address_input: String::new(), show_advanced_options: false, completed_fee_result: None, + refresh_banner: None, } } @@ -146,8 +148,8 @@ impl TransferScreen { // Check if input should be disabled when operation is in progress let enabled = match self.transfer_credits_status { - TransferCreditsStatus::WaitingForResult(_) | TransferCreditsStatus::Complete => false, - TransferCreditsStatus::NotStarted | TransferCreditsStatus::ErrorMessage(_) => { + TransferCreditsStatus::WaitingForResult | TransferCreditsStatus::Complete => false, + TransferCreditsStatus::NotStarted | TransferCreditsStatus::Error => { amount_input.set_max_amount(Some(max_amount_credits)); true } @@ -311,18 +313,24 @@ impl TransferScreen { // Get the amount let credits = self.amount.as_ref().map(|v| v.value()).unwrap_or_default() as u128; if credits == 0 { - self.error_message = Some("Amount must be greater than 0".to_string()); - self.transfer_credits_status = - TransferCreditsStatus::ErrorMessage("Amount must be greater than 0".to_string()); + self.transfer_credits_status = TransferCreditsStatus::Error; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Amount must be greater than 0", + MessageType::Error, + ); return AppAction::None; } // Set waiting state - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - self.transfer_credits_status = TransferCreditsStatus::WaitingForResult(now); + self.transfer_credits_status = TransferCreditsStatus::WaitingForResult; + let handle = MessageBanner::set_global( + self.app_context.egui_ctx(), + "Transferring credits...", + MessageType::Info, + ); + handle.with_elapsed(); + self.refresh_banner = Some(handle); // Build outputs let mut outputs: BTreeMap = BTreeMap::new(); @@ -363,19 +371,25 @@ impl TransferScreen { // Use the amount directly since it's already an Amount struct let credits = self.amount.as_ref().map(|v| v.value()).unwrap_or_default() as u128; if credits == 0 { - self.error_message = Some("Amount must be greater than 0".to_string()); - self.transfer_credits_status = - TransferCreditsStatus::ErrorMessage("Amount must be greater than 0".to_string()); + self.transfer_credits_status = TransferCreditsStatus::Error; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Amount must be greater than 0", + MessageType::Error, + ); self.confirmation_popup = false; return AppAction::None; } // Set waiting state and create backend task - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - self.transfer_credits_status = TransferCreditsStatus::WaitingForResult(now); + self.transfer_credits_status = TransferCreditsStatus::WaitingForResult; + let handle = MessageBanner::set_global( + self.app_context.egui_ctx(), + "Transferring credits...", + MessageType::Info, + ); + handle.with_elapsed(); + self.refresh_banner = Some(handle); AppAction::BackendTask(BackendTask::IdentityTask(IdentityTask::Transfer( self.identity.clone(), @@ -407,8 +421,8 @@ impl TransferScreen { /// Set error state with the given message fn set_error_state(&mut self, error: String) { - self.error_message = Some(error.clone()); - self.transfer_credits_status = TransferCreditsStatus::ErrorMessage(error); + self.transfer_credits_status = TransferCreditsStatus::Error; + MessageBanner::set_global(self.app_context.egui_ctx(), &error, MessageType::Error); } fn show_confirmation_popup(&mut self, ui: &mut Ui) -> AppAction { @@ -487,10 +501,11 @@ impl TransferScreen { } impl ScreenLike for TransferScreen { - fn display_message(&mut self, message: &str, message_type: MessageType) { - if let MessageType::Error = message_type { - self.transfer_credits_status = TransferCreditsStatus::ErrorMessage(message.to_string()); - self.error_message = Some(message.to_string()); + fn display_message(&mut self, _message: &str, message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. + if matches!(message_type, MessageType::Error | MessageType::Warning) { + self.refresh_banner.take_and_clear(); + self.transfer_credits_status = TransferCreditsStatus::Error; } } @@ -498,6 +513,7 @@ impl ScreenLike for TransferScreen { if let BackendTaskSuccessResult::TransferredCredits(fee_result) = backend_task_success_result { + self.refresh_banner.take_and_clear(); self.completed_fee_result = Some(fee_result); self.transfer_credits_status = TransferCreditsStatus::Complete; } @@ -505,13 +521,17 @@ impl ScreenLike for TransferScreen { fn refresh(&mut self) { // Refresh the identity because there might be new keys - let identities = match self.app_context.load_local_qualified_identities() { - Ok(list) => list, - Err(e) => { - tracing::warn!("Failed to load identities during refresh: {}", e); - Vec::new() - } - }; + let identities = self + .app_context + .load_local_qualified_identities() + .unwrap_or_else(|e| { + MessageBanner::set_global( + self.app_context.egui_ctx(), + format!("Failed to load local identities: {e}"), + MessageType::Error, + ); + vec![] + }); if let Some(refreshed) = identities .iter() .find(|identity| identity.identity.id() == self.identity.identity.id()) @@ -596,7 +616,7 @@ impl ScreenLike for TransferScreen { && let Some(wallet) = &self.selected_wallet { if let Err(e) = try_open_wallet_no_password(wallet) { - self.error_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } if wallet_needs_unlock(wallet) { ui.add_space(10.0); @@ -718,7 +738,7 @@ impl ScreenLike for TransferScreen { && has_enough_balance && !matches!( self.transfer_credits_status, - TransferCreditsStatus::WaitingForResult(_), + TransferCreditsStatus::WaitingForResult, ) && match self.destination_type { TransferDestinationType::Identity => !self.receiver_identity_id.is_empty(), @@ -762,67 +782,7 @@ impl ScreenLike for TransferScreen { }; } - // Handle transfer status messages - ui.add_space(5.0); - match &self.transfer_credits_status { - TransferCreditsStatus::NotStarted => { - // Do nothing - } - TransferCreditsStatus::WaitingForResult(start_time) => { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let elapsed_seconds = now - start_time; - - let display_time = if elapsed_seconds < 60 { - format!( - "{} second{}", - elapsed_seconds, - if elapsed_seconds == 1 { "" } else { "s" } - ) - } else { - let minutes = elapsed_seconds / 60; - let seconds = elapsed_seconds % 60; - format!( - "{} minute{} and {} second{}", - minutes, - if minutes == 1 { "" } else { "s" }, - seconds, - if seconds == 1 { "" } else { "s" } - ) - }; - - ui.label(format!( - "Transferring... Time taken so far: {}", - display_time - )); - } - TransferCreditsStatus::ErrorMessage(msg) => { - let error_color = DashColors::ERROR; - let msg = msg.clone(); - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label( - RichText::new(format!("Error: {}", msg)).color(error_color), - ); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.transfer_credits_status = - TransferCreditsStatus::NotStarted; - } - }); - }); - } - TransferCreditsStatus::Complete => { - // Handled above - } - } + // Status display is handled by the global MessageBanner } inner_action diff --git a/src/ui/identities/withdraw_screen.rs b/src/ui/identities/withdraw_screen.rs index b3de54760..bebd416e2 100644 --- a/src/ui/identities/withdraw_screen.rs +++ b/src/ui/identities/withdraw_screen.rs @@ -15,6 +15,7 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; +use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt, ResultBannerExt}; use crate::ui::components::{Component, ComponentResponse}; use crate::ui::helpers::{TransactionType, add_key_chooser}; use crate::ui::theme::DashColors; @@ -25,13 +26,11 @@ use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; use dash_sdk::dpp::platform_value::string_encoding::Encoding; -use dash_sdk::dpp::prelude::TimestampMillis; use dash_sdk::platform::IdentityPublicKey; use eframe::egui::{self, Context, Frame, Margin, Ui}; use egui::{Color32, RichText}; use std::str::FromStr; use std::sync::{Arc, RwLock}; -use std::time::{SystemTime, UNIX_EPOCH}; use super::get_selected_wallet; use super::keys::add_key_screen::AddKeyScreen; @@ -40,8 +39,8 @@ use super::keys::key_info_screen::KeyInfoScreen; #[derive(PartialEq)] pub enum WithdrawFromIdentityStatus { NotStarted, - WaitingForResult(TimestampMillis), - ErrorMessage(String), + WaitingForResult, + Error, Complete, } @@ -58,10 +57,10 @@ pub struct WithdrawalScreen { withdraw_from_identity_status: WithdrawFromIdentityStatus, selected_wallet: Option>>, wallet_unlock_popup: WalletUnlockPopup, - error_message: Option, show_advanced_options: bool, // Fee result from completed operation completed_fee_result: Option, + refresh_banner: Option, } impl WithdrawalScreen { @@ -74,9 +73,9 @@ impl WithdrawalScreen { KeyType::all_key_types().into(), false, ); - let mut error_message = None; - let selected_wallet = - get_selected_wallet(&identity, None, selected_key, &mut error_message); + let selected_wallet = get_selected_wallet(&identity, None, selected_key) + .or_show_error(app_context.egui_ctx()) + .unwrap_or(None); Self { identity, selected_key: selected_key.cloned(), @@ -90,9 +89,9 @@ impl WithdrawalScreen { withdraw_from_identity_status: WithdrawFromIdentityStatus::NotStarted, selected_wallet, wallet_unlock_popup: WalletUnlockPopup::new(), - error_message, show_advanced_options: false, completed_fee_result: None, + refresh_banner: None, } } @@ -119,10 +118,10 @@ impl WithdrawalScreen { // Check if input should be disabled when operation is in progress let enabled = match self.withdraw_from_identity_status { - WithdrawFromIdentityStatus::WaitingForResult(_) - | WithdrawFromIdentityStatus::Complete => false, - WithdrawFromIdentityStatus::NotStarted - | WithdrawFromIdentityStatus::ErrorMessage(_) => { + WithdrawFromIdentityStatus::WaitingForResult | WithdrawFromIdentityStatus::Complete => { + false + } + WithdrawFromIdentityStatus::NotStarted | WithdrawFromIdentityStatus::Error => { amount_input.set_max_amount(Some(max_amount_credits)); true } @@ -240,8 +239,11 @@ impl WithdrawalScreen { { format!("masternode payout address {}", payout_address) } else if !self.app_context.is_developer_mode() { - self.withdraw_from_identity_status = WithdrawFromIdentityStatus::ErrorMessage( - "No masternode payout address".to_string(), + self.withdraw_from_identity_status = WithdrawFromIdentityStatus::Error; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "No masternode payout address", + MessageType::Error, ); self.confirmation_dialog = None; return AppAction::None; @@ -250,8 +252,12 @@ impl WithdrawalScreen { }; let Some(selected_key) = self.selected_key.as_ref() else { - self.withdraw_from_identity_status = - WithdrawFromIdentityStatus::ErrorMessage("No selected key".to_string()); + self.withdraw_from_identity_status = WithdrawFromIdentityStatus::Error; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "No selected key", + MessageType::Error, + ); self.confirmation_dialog = None; return AppAction::None; }; @@ -273,12 +279,14 @@ impl WithdrawalScreen { match dialog.show(ui).inner.dialog_response { Some(ConfirmationStatus::Confirmed) => { self.confirmation_dialog = None; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - self.withdraw_from_identity_status = - WithdrawFromIdentityStatus::WaitingForResult(now); + self.withdraw_from_identity_status = WithdrawFromIdentityStatus::WaitingForResult; + let handle = MessageBanner::set_global( + self.app_context.egui_ctx(), + "Withdrawing from identity...", + MessageType::Info, + ); + handle.with_elapsed(); + self.refresh_banner = Some(handle); // Use the amount directly from the stored amount let credits = self @@ -318,10 +326,11 @@ impl WithdrawalScreen { } impl ScreenLike for WithdrawalScreen { - fn display_message(&mut self, message: &str, message_type: MessageType) { - if let MessageType::Error = message_type { - self.withdraw_from_identity_status = - WithdrawFromIdentityStatus::ErrorMessage(message.to_string()); + fn display_message(&mut self, _message: &str, message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. + if matches!(message_type, MessageType::Error | MessageType::Warning) { + self.refresh_banner.take_and_clear(); + self.withdraw_from_identity_status = WithdrawFromIdentityStatus::Error; } } @@ -329,6 +338,7 @@ impl ScreenLike for WithdrawalScreen { if let BackendTaskSuccessResult::WithdrewFromIdentity(fee_result) = backend_task_success_result { + self.refresh_banner.take_and_clear(); self.completed_fee_result = Some(fee_result); self.withdraw_from_identity_status = WithdrawFromIdentityStatus::Complete; } @@ -339,7 +349,14 @@ impl ScreenLike for WithdrawalScreen { if let Some(refreshed) = self .app_context .load_local_qualified_identities() - .unwrap_or_default() + .unwrap_or_else(|e| { + MessageBanner::set_global( + self.app_context.egui_ctx(), + format!("Failed to load local identities: {e}"), + MessageType::Error, + ); + vec![] + }) .into_iter() .find(|identity| identity.identity.id() == self.identity.identity.id()) { @@ -474,7 +491,7 @@ impl ScreenLike for WithdrawalScreen { if let Some(wallet) = &self.selected_wallet { if let Err(e) = try_open_wallet_no_password(wallet) { - self.error_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } if wallet_needs_unlock(wallet) { ui.add_space(10.0); @@ -604,71 +621,7 @@ impl ScreenLike for WithdrawalScreen { inner_action |= self.show_confirmation_popup(ui); } - ui.add_space(10.0); - - // Handle withdrawal status messages - match &self.withdraw_from_identity_status { - WithdrawFromIdentityStatus::NotStarted => { - // Do nothing - } - WithdrawFromIdentityStatus::WaitingForResult(start_time) => { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let elapsed_seconds = now.saturating_sub(*start_time); - - let display_time = if elapsed_seconds < 60 { - format!( - "{} second{}", - elapsed_seconds, - if elapsed_seconds == 1 { "" } else { "s" } - ) - } else { - let minutes = elapsed_seconds / 60; - let seconds = elapsed_seconds % 60; - format!( - "{} minute{} and {} second{}", - minutes, - if minutes == 1 { "" } else { "s" }, - seconds, - if seconds == 1 { "" } else { "s" } - ) - }; - - ui.label(format!( - "Withdrawing... Time taken so far: {}", - display_time - )); - } - WithdrawFromIdentityStatus::ErrorMessage(msg) => { - let error_color = DashColors::ERROR; - let msg = msg.clone(); - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label( - RichText::new(format!("Error: {}", msg)).color(error_color), - ); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.withdraw_from_identity_status = - WithdrawFromIdentityStatus::NotStarted; - } - }); - }); - } - WithdrawFromIdentityStatus::Complete => { - ui.colored_label( - egui::Color32::DARK_GREEN, - "Successfully withdrew from identity".to_string(), - ); - } - } + // Status display is handled by the global MessageBanner } inner_action diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 84b8ac96b..08e312893 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -840,10 +840,21 @@ pub trait ScreenLike { self.refresh() } fn ui(&mut self, ctx: &Context) -> AppAction; + /// Called by `AppState` **after** the global banner has already been set. + /// + /// Override **only for side-effects** such as clearing a progress banner + /// (`self.refresh_banner.take_and_clear()`) or updating an internal status enum. + /// Do **not** set your own banner here — `AppState` already did that. fn display_message(&mut self, _message: &str, _message_type: MessageType) {} - fn display_task_result(&mut self, _backend_task_success_result: BackendTaskSuccessResult) { - self.display_message("Success", MessageType::Success) - } + + /// Called by `AppState` when a backend task completes successfully. + /// + /// Global success/error banners are handled centrally by `AppState::update()`. + /// Override this to perform screen-specific side-effects (e.g., storing a + /// result, transitioning status, clearing a progress banner). + /// The default is a **no-op** — screens that dispatch backend tasks should + /// override this for their expected result variants. + fn display_task_result(&mut self, _backend_task_success_result: BackendTaskSuccessResult) {} fn pop_on_success(&mut self) {} } diff --git a/src/ui/network_chooser_screen.rs b/src/ui/network_chooser_screen.rs index 61e4034f4..681fc0f13 100644 --- a/src/ui/network_chooser_screen.rs +++ b/src/ui/network_chooser_screen.rs @@ -20,7 +20,7 @@ use crate::utils::path::format_path_for_display; use dash_sdk::dash_spv::sync::{ProgressPercentage, SyncProgress as SpvSyncProgress, SyncState}; use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::identity::TimestampMillis; -use eframe::egui::{self, Color32, Context, Frame, Margin, RichText, Ui}; +use eframe::egui::{self, Context, Ui}; use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; @@ -90,7 +90,6 @@ pub struct NetworkChooserScreen { pub current_network: Network, pub recheck_time: Option, custom_dash_qt_path: Option, - custom_dash_qt_error_message: Option, overwrite_dash_conf: bool, disable_zmq: bool, developer_mode: bool, @@ -189,7 +188,6 @@ impl NetworkChooserScreen { current_network, recheck_time: None, custom_dash_qt_path, - custom_dash_qt_error_message: None, overwrite_dash_conf, disable_zmq, developer_mode, @@ -901,8 +899,6 @@ impl NetworkChooserScreen { let previous_custom_dash_qt_path = self.custom_dash_qt_path.clone(); let file_name = path.file_name().and_then(|f| f.to_str()); if let Some(file_name) = file_name { - self.custom_dash_qt_error_message = None; - // Handle macOS .app bundles let resolved_path = if cfg!(target_os = "macos") && path.extension().and_then(|s| s.to_str()) == Some("app") @@ -925,12 +921,12 @@ impl NetworkChooserScreen { if is_valid { self.custom_dash_qt_path = Some(resolved_path); - self.custom_dash_qt_error_message = None; if let Err(e) = self.save() { tracing::warn!("Failed to save Dash-Qt path setting: {}", e); - self.custom_dash_qt_error_message = Some( - "Failed to save Dash-Qt path setting. Please try again." - .to_string(), + MessageBanner::set_global( + ui.ctx(), + "Failed to save Dash-Qt path setting. Please try again.", + MessageType::Error, ); self.custom_dash_qt_path = previous_custom_dash_qt_path; } @@ -942,10 +938,14 @@ impl NetworkChooserScreen { } else { "dash-qt" }; - self.custom_dash_qt_error_message = Some(format!( - "Invalid file: Please select a valid '{}'.", - required_file_name - )); + MessageBanner::set_global( + ui.ctx(), + format!( + "Invalid file: Please select a valid '{}'.", + required_file_name + ), + MessageType::Error, + ); } } } @@ -953,12 +953,12 @@ impl NetworkChooserScreen { if self.custom_dash_qt_path.is_some() && ui.button("Clear").clicked() { let previous_custom_dash_qt_path = self.custom_dash_qt_path.clone(); self.custom_dash_qt_path = Some(PathBuf::new()); - self.custom_dash_qt_error_message = None; if let Err(e) = self.save() { tracing::warn!("Failed to save cleared Dash-Qt path setting: {}", e); - self.custom_dash_qt_error_message = Some( - "Failed to clear Dash-Qt path setting. Please try again." - .to_string(), + MessageBanner::set_global( + ui.ctx(), + "Failed to clear Dash-Qt path setting. Please try again.", + MessageType::Error, ); self.custom_dash_qt_path = previous_custom_dash_qt_path; } @@ -977,24 +977,6 @@ impl NetworkChooserScreen { ); }); } - if let Some(ref error) = self.custom_dash_qt_error_message { - let error_color = Color32::from_rgb(255, 100, 100); - let error = error.clone(); - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label(RichText::new(&error).color(error_color)); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.custom_dash_qt_error_message = None; - } - }); - }); - } // Configuration Options ui.add_space(10.0); @@ -1012,16 +994,15 @@ impl NetworkChooserScreen { if StyledCheckbox::new(&mut self.overwrite_dash_conf, "Overwrite dash.conf") .show(ui) .clicked() + && let Err(e) = self.save() { - self.custom_dash_qt_error_message = None; - if let Err(e) = self.save() { - tracing::warn!("Failed to save overwrite_dash_conf setting: {}", e); - self.custom_dash_qt_error_message = Some( - "Failed to save overwrite dash.conf setting. Please try again." - .to_string(), - ); - self.overwrite_dash_conf = previous_overwrite_dash_conf; - } + tracing::warn!("Failed to save overwrite_dash_conf setting: {}", e); + MessageBanner::set_global( + ui.ctx(), + "Failed to save overwrite dash.conf setting. Please try again.", + MessageType::Error, + ); + self.overwrite_dash_conf = previous_overwrite_dash_conf; } ui.label( egui::RichText::new("Auto-configure required settings") diff --git a/src/ui/tokens/add_token_by_id_screen.rs b/src/ui/tokens/add_token_by_id_screen.rs index 95d469c42..964784ccd 100644 --- a/src/ui/tokens/add_token_by_id_screen.rs +++ b/src/ui/tokens/add_token_by_id_screen.rs @@ -13,6 +13,7 @@ use eframe::egui::{self, Color32, Context, RichText, Ui}; use crate::backend_task::BackendTaskSuccessResult; use crate::backend_task::contract::ContractTask; use crate::database::contracts::InsertTokensToo; +use crate::ui::components::MessageBanner; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::tokens_subscreen_chooser_panel::add_tokens_subscreen_chooser_panel; @@ -21,7 +22,7 @@ use crate::{ app::AppAction, backend_task::{BackendTask, tokens::TokenTask}, context::AppContext, - ui::{MessageType, ScreenLike, components::top_panel::add_top_panel, theme::DashColors}, + ui::{MessageType, ScreenLike, components::top_panel::add_top_panel}, }; /// UI state during the add-token flow. @@ -31,7 +32,7 @@ enum AddTokenStatus { Searching(u32), FoundSingle(Box), FoundMultiple(Vec), - Error(String), + Error, Complete, } @@ -44,7 +45,6 @@ pub struct AddTokenByIdScreen { status: AddTokenStatus, selected_token: Option, - error_message: Option, try_token_id_next: bool, } @@ -56,7 +56,6 @@ impl AddTokenByIdScreen { fetched_contract: None, status: AddTokenStatus::Idle, selected_token: None, - error_message: None, try_token_id_next: false, } } @@ -79,7 +78,6 @@ impl AddTokenByIdScreen { { let now = Utc::now().timestamp() as u32; self.status = AddTokenStatus::Searching(now); - self.error_message = None; if !self.contract_or_token_id_input.is_empty() { // Try to parse as identifier @@ -91,7 +89,12 @@ impl AddTokenByIdScreen { TokenTask::FetchTokenByContractId(identifier), ))); } else { - self.status = AddTokenStatus::Error("Invalid identifier format".into()); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Invalid identifier format", + MessageType::Error, + ); + self.status = AddTokenStatus::Error; } } } @@ -196,7 +199,12 @@ impl AddTokenByIdScreen { ) { // 1. Bail out if the contract has no tokens if contract.tokens().is_empty() { - self.status = AddTokenStatus::Error("Contract has no token definitions".into()); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Contract has no token definitions", + MessageType::Error, + ); + self.status = AddTokenStatus::Error; return; } @@ -232,7 +240,12 @@ impl AddTokenByIdScreen { { self.status = AddTokenStatus::FoundSingle(Box::new(token_info)); } else { - self.status = AddTokenStatus::Error("Token position not found in contract".into()); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Token position not found in contract", + MessageType::Error, + ); + self.status = AddTokenStatus::Error; return; } } else if token_infos.len() == 1 { @@ -250,6 +263,7 @@ impl AddTokenByIdScreen { impl ScreenLike for AddTokenByIdScreen { fn display_message(&mut self, msg: &str, msg_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. match msg_type { MessageType::Success => { if msg.contains("DataContract successfully saved") { @@ -262,22 +276,16 @@ impl ScreenLike for AddTokenByIdScreen { // We'll initiate a token ID search self.try_token_id_next = true; } else { - self.status = AddTokenStatus::Error("Contract not found".into()); + self.status = AddTokenStatus::Error; } - } else if msg.contains("Token not found") { - self.status = AddTokenStatus::Error("Token not found".into()); - } else if msg.contains("Error fetching contracts") { - self.status = AddTokenStatus::Error(msg.to_owned()); + } else if msg.contains("Token not found") + || msg.contains("Error fetching contracts") + { + self.status = AddTokenStatus::Error; } } MessageType::Error | MessageType::Warning => { - // Handle any error during the add token process - if msg.contains("Error inserting contract into the database") { - self.status = AddTokenStatus::Error("Failed to add token to database".into()); - } else { - self.status = AddTokenStatus::Error(msg.to_owned()); - } - self.error_message = Some(msg.to_owned()); + self.status = AddTokenStatus::Error; } MessageType::Info => {} } @@ -324,8 +332,6 @@ impl ScreenLike for AddTokenByIdScreen { action |= add_tokens_subscreen_chooser_panel(ctx, &self.app_context); action |= island_central_panel(ctx, |ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; - // If we are in the "Complete" status, just show success screen if self.status == AddTokenStatus::Complete { return self.show_success_screen(ui); @@ -375,26 +381,9 @@ impl ScreenLike for AddTokenByIdScreen { ui.add_space(10.0); self.render_search_results(ui); - if let AddTokenStatus::Error(err) = &self.status { - ui.add_space(10.0); - ui.colored_label( - DashColors::error_color(dark_mode), - format!("Error: {}", err), - ); - } - ui.add_space(10.0); inner_action |= self.render_add_button(ui); - // Show any additional error messages - if let Some(error_msg) = &self.error_message { - ui.add_space(5.0); - ui.colored_label( - DashColors::error_color(dark_mode), - format!("Details: {}", error_msg), - ); - } - inner_action }); diff --git a/src/ui/tokens/burn_tokens_screen.rs b/src/ui/tokens/burn_tokens_screen.rs index 1ca585a5d..39ccba1b0 100644 --- a/src/ui/tokens/burn_tokens_screen.rs +++ b/src/ui/tokens/burn_tokens_screen.rs @@ -1,14 +1,16 @@ use crate::backend_task::{BackendTaskSuccessResult, FeeResult}; use crate::model::fee_estimation::format_credits_as_dash; +use crate::ui::components::MessageBanner; use crate::ui::components::amount_input::AmountInput; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::tokens_subscreen_chooser_panel::add_tokens_subscreen_chooser_panel; -use crate::ui::components::{Component, ComponentResponse}; +use crate::ui::components::{BannerHandle, Component, ComponentResponse, OptionBannerExt}; use crate::ui::helpers::{TransactionType, add_key_chooser, render_group_action_text}; use crate::ui::theme::DashColors; use crate::ui::tokens::tokens_screen::IdentityTokenIdentifier; +use crate::ui::tokens::validate_signing_key; use dash_sdk::dpp::data_contract::GroupContractPosition; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::data_contract::accessors::v1::DataContractV1Getters; @@ -25,7 +27,6 @@ use eframe::egui::{Frame, Margin}; use egui::RichText; use std::collections::HashSet; use std::sync::{Arc, RwLock}; -use std::time::{SystemTime, UNIX_EPOCH}; use crate::app::{AppAction, BackendTasksExecutionMode}; use crate::backend_task::BackendTask; @@ -48,8 +49,8 @@ use super::tokens_screen::IdentityTokenInfo; #[derive(PartialEq)] pub enum BurnTokensStatus { NotStarted, - WaitingForResult(u64), - ErrorMessage(String), + WaitingForResult, + Error, Complete, } @@ -63,12 +64,11 @@ pub struct BurnTokensScreen { // The user chooses how many tokens to burn pub amount: Option, - pub amount_input: Option, - pub max_amount: Option, // Maximum amount the user can burn based on their balance + amount_input: Option, + max_amount: Option, // Maximum amount the user can burn based on their balance pub public_note: Option, status: BurnTokensStatus, - error_message: Option, // Basic references pub app_context: Arc, @@ -81,6 +81,8 @@ pub struct BurnTokensScreen { wallet_unlock_popup: WalletUnlockPopup, // Fee result from completed operation completed_fee_result: Option, + // Banner handle for elapsed time display + refresh_banner: Option, } impl BurnTokensScreen { @@ -108,7 +110,7 @@ impl BurnTokensScreen { ) .cloned(); - let mut error_message = None; + let set_error_banner = |msg: &str| super::set_error_banner(app_context, msg); let group = match identity_token_info .token_config @@ -116,32 +118,30 @@ impl BurnTokensScreen { .authorized_to_make_change_action_takers() { AuthorizedActionTakers::NoOne => { - error_message = Some("Burning is not allowed on this token".to_string()); + set_error_banner("Burning is not allowed on this token"); None } AuthorizedActionTakers::ContractOwner => { if identity_token_info.data_contract.contract.owner_id() != identity_token_info.identity.identity.id() { - error_message = Some( - "You are not allowed to burn this token. Only the contract owner is." - .to_string(), + set_error_banner( + "You are not allowed to burn this token. Only the contract owner is.", ); } None } AuthorizedActionTakers::Identity(identifier) => { if identifier != &identity_token_info.identity.identity.id() { - error_message = Some("You are not allowed to burn this token".to_string()); + set_error_banner("You are not allowed to burn this token"); } None } AuthorizedActionTakers::MainGroup => { match identity_token_info.token_config.main_control_group() { None => { - error_message = Some( - "Invalid contract: No main control group, though one should exist" - .to_string(), + set_error_banner( + "Invalid contract: No main control group, though one should exist", ); None } @@ -153,7 +153,7 @@ impl BurnTokensScreen { { Ok(group) => Some((group_pos, group.clone())), Err(e) => { - error_message = Some(format!("Invalid contract: {}", e)); + set_error_banner(&format!("Invalid contract: {}", e)); None } } @@ -168,7 +168,7 @@ impl BurnTokensScreen { { Ok(group) => Some((*group_pos, group.clone())), Err(e) => { - error_message = Some(format!("Invalid contract: {}", e)); + set_error_banner(&format!("Invalid contract: {}", e)); None } } @@ -191,12 +191,12 @@ impl BurnTokensScreen { }; // Attempt to get an unlocked wallet reference - let selected_wallet = get_selected_wallet( - &identity_token_info.identity, - None, - possible_key.as_ref(), - &mut error_message, - ); + let selected_wallet = + get_selected_wallet(&identity_token_info.identity, None, possible_key.as_ref()) + .unwrap_or_else(|e| { + set_error_banner(&e); + None + }); Self { identity_token_info, @@ -210,12 +210,12 @@ impl BurnTokensScreen { max_amount: token_balance, public_note: None, status: BurnTokensStatus::NotStarted, - error_message, app_context: app_context.clone(), confirmation_dialog: None, selected_wallet, wallet_unlock_popup: WalletUnlockPopup::new(), completed_fee_result: None, + refresh_banner: None, } } @@ -244,8 +244,12 @@ impl BurnTokensScreen { let amount = match self.amount.as_ref() { Some(amount) if amount.value() > 0 => amount, _ => { - self.error_message = Some("Please enter a valid amount greater than 0.".into()); - self.status = BurnTokensStatus::ErrorMessage("Invalid amount".into()); + self.status = BurnTokensStatus::Error; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Please enter a valid amount greater than 0.", + MessageType::Error, + ); self.confirmation_dialog = None; return AppAction::None; } @@ -262,22 +266,33 @@ impl BurnTokensScreen { match dialog.show(ui).inner.dialog_response { Some(ConfirmationStatus::Confirmed) => { self.confirmation_dialog = None; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - self.status = BurnTokensStatus::WaitingForResult(now); + + // Validate signing key before transitioning to waiting state + let Some(signing_key) = + validate_signing_key(&self.app_context, self.selected_key.as_ref()) + else { + return AppAction::None; + }; + + self.status = BurnTokensStatus::WaitingForResult; + let handle = MessageBanner::set_global( + self.app_context.egui_ctx(), + "Burning tokens...", + MessageType::Info, + ); + handle.with_elapsed(); + self.refresh_banner = Some(handle); // Grab the data contract for this token from the app context let data_contract = Arc::new(self.identity_token_info.data_contract.contract.clone()); - let group_info = if self.group_action_id.is_some() { + let group_info = if let Some(action_id) = self.group_action_id { self.group.as_ref().map(|(pos, _)| { GroupStateTransitionInfoStatus::GroupStateTransitionInfoOtherSigner( GroupStateTransitionInfo { group_contract_position: *pos, - action_id: self.group_action_id.unwrap(), + action_id, action_is_proposer: false, }, ) @@ -295,7 +310,7 @@ impl BurnTokensScreen { owner_identity: self.identity_token_info.identity.clone(), data_contract, token_position: self.identity_token_info.token_position, - signing_key: self.selected_key.clone().expect("Expected a key"), + signing_key, public_note: if self.group_action_id.is_some() { None } else { @@ -332,15 +347,17 @@ impl BurnTokensScreen { } impl ScreenLike for BurnTokensScreen { - fn display_message(&mut self, message: &str, message_type: MessageType) { - if let MessageType::Error = message_type { - self.status = BurnTokensStatus::ErrorMessage(message.to_string()); - self.error_message = Some(message.to_string()); + fn display_message(&mut self, _message: &str, message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. + if matches!(message_type, MessageType::Error | MessageType::Warning) { + self.refresh_banner.take_and_clear(); + self.status = BurnTokensStatus::Error; } } fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { if let BackendTaskSuccessResult::BurnedTokens(fee_result) = backend_task_success_result { + self.refresh_banner.take_and_clear(); self.completed_fee_result = Some(fee_result); self.status = BurnTokensStatus::Complete; } @@ -466,7 +483,7 @@ impl ScreenLike for BurnTokensScreen { // Possibly handle locked wallet scenario if let Some(wallet) = &self.selected_wallet { if let Err(e) = try_open_wallet_no_password(wallet) { - self.error_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } if wallet_needs_unlock(wallet) { ui.add_space(10.0); @@ -652,19 +669,11 @@ impl ScreenLike for BurnTokensScreen { BurnTokensStatus::NotStarted => { // no-op } - BurnTokensStatus::WaitingForResult(start_time) => { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let elapsed = now - start_time; - ui.label(format!("Burning... elapsed: {} seconds", elapsed)); + BurnTokensStatus::WaitingForResult => { + // Elapsed display is handled by the global MessageBanner } - BurnTokensStatus::ErrorMessage(msg) => { - ui.colored_label( - DashColors::error_color(dark_mode), - format!("Error: {}", msg), - ); + BurnTokensStatus::Error => { + // Error display is handled by the global MessageBanner } BurnTokensStatus::Complete => { // handled above diff --git a/src/ui/tokens/claim_tokens_screen.rs b/src/ui/tokens/claim_tokens_screen.rs index 850955b08..f0534f500 100644 --- a/src/ui/tokens/claim_tokens_screen.rs +++ b/src/ui/tokens/claim_tokens_screen.rs @@ -5,10 +5,11 @@ use crate::ui::components::confirmation_dialog::{ConfirmationDialog, Confirmatio use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::tokens_subscreen_chooser_panel::add_tokens_subscreen_chooser_panel; +use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt, ResultBannerExt}; use crate::ui::helpers::{TransactionType, add_key_chooser}; use std::collections::HashSet; use std::sync::{Arc, RwLock}; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::Duration; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; @@ -35,6 +36,7 @@ use crate::ui::{MessageType, Screen, ScreenLike}; use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{wallet_needs_unlock, try_open_wallet_no_password, WalletUnlockPopup, WalletUnlockResult}; use crate::ui::identities::get_selected_wallet; +use crate::ui::tokens::validate_signing_key; use crate::ui::identities::keys::add_key_screen::AddKeyScreen; use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; use super::tokens_screen::IdentityTokenBasicInfo; @@ -43,28 +45,29 @@ use super::tokens_screen::IdentityTokenBasicInfo; #[derive(PartialEq)] pub enum ClaimTokensStatus { NotStarted, - WaitingForResult(u64), - ErrorMessage(String), + WaitingForResult, + Error, Complete, } pub struct ClaimTokensScreen { - pub identity: QualifiedIdentity, + identity: Option, pub identity_token_basic_info: IdentityTokenBasicInfo, selected_key: Option, show_advanced_options: bool, - pub public_note: Option, + public_note: Option, token_contract: QualifiedContract, token_configuration: TokenConfiguration, distribution_type: Option, status: ClaimTokensStatus, - error_message: Option, pub app_context: Arc, confirmation_dialog: Option, selected_wallet: Option>>, wallet_unlock_popup: WalletUnlockPopup, // Fee result from completed operation completed_fee_result: Option, + // Banner handle for elapsed time display + refresh_banner: Option, } impl ClaimTokensScreen { @@ -74,33 +77,58 @@ impl ClaimTokensScreen { token_configuration: TokenConfiguration, app_context: &Arc, ) -> Self { - let identity = app_context + let known_identities = app_context .load_local_qualified_identities() - .unwrap_or_default() - .into_iter() - .find(|id| id.identity.id() == identity_token_basic_info.identity_id) - .expect("No local qualified identity found for this token’s identity."); - - let identity_clone = identity.identity.clone(); - let mut possible_key = identity_clone.get_first_public_key_matching( - Purpose::AUTHENTICATION, - HashSet::from([SecurityLevel::CRITICAL]), - KeyType::all_key_types().into(), - false, - ); + .or_show_error(app_context.egui_ctx()) + .unwrap_or_default(); - if possible_key.is_none() { - possible_key = identity_clone.get_first_public_key_matching( - Purpose::TRANSFER, - HashSet::from([SecurityLevel::CRITICAL]), - KeyType::all_key_types().into(), - false, + let identity = known_identities + .iter() + .find(|id| id.identity.id() == identity_token_basic_info.identity_id) + .cloned() + .or_else(|| { + MessageBanner::set_global( + app_context.egui_ctx(), + "Identity not found in local store", + MessageType::Error, + ); + // Fallback to first available identity for degraded state. + known_identities.first().cloned() + }); + + if identity.is_none() { + MessageBanner::set_global( + app_context.egui_ctx(), + "No identities loaded — cannot open Claim screen", + MessageType::Error, ); } - let mut error_message = None; - let selected_wallet = - get_selected_wallet(&identity, None, possible_key, &mut error_message); + let possible_key: Option = + identity.as_ref().and_then(|id| { + id.identity + .get_first_public_key_matching( + Purpose::AUTHENTICATION, + HashSet::from([SecurityLevel::CRITICAL]), + KeyType::all_key_types().into(), + false, + ) + .or_else(|| { + id.identity.get_first_public_key_matching( + Purpose::TRANSFER, + HashSet::from([SecurityLevel::CRITICAL]), + KeyType::all_key_types().into(), + false, + ) + }) + .cloned() + }); + + let selected_wallet = identity.as_ref().and_then(|id| { + get_selected_wallet(id, None, possible_key.as_ref()) + .or_show_error(app_context.egui_ctx()) + .unwrap_or(None) + }); let distribution_type = match ( token_configuration @@ -121,23 +149,24 @@ impl ClaimTokensScreen { Self { identity, identity_token_basic_info, - selected_key: possible_key.cloned(), + selected_key: possible_key, show_advanced_options: false, public_note: None, token_contract, token_configuration, distribution_type, status: ClaimTokensStatus::NotStarted, - error_message, app_context: app_context.clone(), confirmation_dialog: None, selected_wallet, wallet_unlock_popup: WalletUnlockPopup::new(), completed_fee_result: None, + refresh_banner: None, } } fn render_token_distribution_type_selector(&mut self, ui: &mut Ui) { + let identity_id = self.identity.as_ref().map(|id| id.identity.id()); let show_perpetual = if let Some(perpetual_distribution) = self .token_configuration .distribution_rules() @@ -145,9 +174,11 @@ impl ClaimTokensScreen { { match perpetual_distribution.distribution_recipient() { TokenDistributionRecipient::ContractOwner => { - self.token_contract.contract.owner_id() == self.identity.identity.id() + identity_id.is_some_and(|id| self.token_contract.contract.owner_id() == id) + } + TokenDistributionRecipient::Identity(id) => { + identity_id.is_some_and(|self_id| self_id == id) } - TokenDistributionRecipient::Identity(id) => self.identity.identity.id() == id, TokenDistributionRecipient::EvonodesByParticipation => true, } } else { @@ -204,27 +235,37 @@ impl ClaimTokensScreen { Some(ConfirmationStatus::Confirmed) => { self.confirmation_dialog = None; - let signing_key = match self.selected_key.clone() { - Some(key) => key, - None => { - self.error_message = Some("No signing key selected".into()); - self.status = ClaimTokensStatus::ErrorMessage("No key selected".into()); - return AppAction::None; - } + let Some(identity) = self.identity.clone() else { + MessageBanner::set_global( + self.app_context.egui_ctx(), + "No identity loaded — cannot claim tokens", + MessageType::Error, + ); + return AppAction::None; + }; + + // Validate signing key before transitioning to waiting state + let Some(signing_key) = + validate_signing_key(&self.app_context, self.selected_key.as_ref()) + else { + return AppAction::None; }; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - self.status = ClaimTokensStatus::WaitingForResult(now); + self.status = ClaimTokensStatus::WaitingForResult; + let handle = MessageBanner::set_global( + self.app_context.egui_ctx(), + "Claiming tokens...", + MessageType::Info, + ); + handle.with_elapsed(); + self.refresh_banner = Some(handle); AppAction::BackendTasks( vec![ BackendTask::TokenTask(Box::new(TokenTask::ClaimTokens { data_contract: Arc::new(self.token_contract.contract.clone()), token_position: self.identity_token_basic_info.token_position, - actor_identity: self.identity.clone(), + actor_identity: identity, distribution_type, signing_key, public_note: self.public_note.clone(), @@ -253,27 +294,29 @@ impl ClaimTokensScreen { } impl ScreenLike for ClaimTokensScreen { - fn display_message(&mut self, message: &str, message_type: MessageType) { - if let MessageType::Error = message_type { - self.status = ClaimTokensStatus::ErrorMessage(message.to_string()); - self.error_message = Some(message.to_string()); + fn display_message(&mut self, _message: &str, message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. + if matches!(message_type, MessageType::Error | MessageType::Warning) { + self.refresh_banner.take_and_clear(); + self.status = ClaimTokensStatus::Error; } } fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { if let BackendTaskSuccessResult::ClaimedTokens(fee_result) = backend_task_success_result { + self.refresh_banner.take_and_clear(); self.completed_fee_result = Some(fee_result); self.status = ClaimTokensStatus::Complete; } } fn refresh(&mut self) { - if let Ok(all) = self.app_context.load_local_qualified_identities() - && let Some(updated) = all - .into_iter() - .find(|id| id.identity.id() == self.identity.identity.id()) + let current_id = self.identity.as_ref().map(|id| id.identity.id()); + if let Some(current_id) = current_id + && let Ok(all) = self.app_context.load_local_qualified_identities() + && let Some(updated) = all.into_iter().find(|id| id.identity.id() == current_id) { - self.identity = updated; + self.identity = Some(updated); } } @@ -308,46 +351,61 @@ impl ScreenLike for ClaimTokensScreen { return; } + // Guard: require a loaded identity before rendering interactive content. + // Use as_ref() to avoid a per-frame clone of the full QualifiedIdentity. + let Some(identity) = self.identity.as_ref() else { + ui.colored_label( + Color32::RED, + "No identity loaded. Please load an identity and reopen this screen.", + ); + return; + }; + ui.heading("Claim Tokens"); ui.add_space(10.0); // Check if user has any auth keys let has_keys = if self.app_context.is_developer_mode() { - !self.identity.identity.public_keys().is_empty() + !identity.identity.public_keys().is_empty() } else { - match self.identity.identity_type { - IdentityType::User => !self - .identity + match identity.identity_type { + IdentityType::User => !identity .available_authentication_keys_with_critical_security_level() .is_empty(), IdentityType::Masternode | IdentityType::Evonode => { - !self.identity.available_transfer_keys().is_empty() + !identity.available_transfer_keys().is_empty() } } }; if !has_keys { + let identity_type = identity.identity_type; + // Extract key data before releasing the borrow (needed for click handlers). + let first_key = identity + .identity + .get_first_public_key_matching( + Purpose::AUTHENTICATION, + HashSet::from([SecurityLevel::CRITICAL]), + KeyType::all_key_types().into(), + false, + ) + .cloned(); ui.colored_label( Color32::RED, format!( "No authentication keys with CRITICAL security level found for this {} identity.", - self.identity.identity_type, + identity_type, ), ); ui.add_space(10.0); - let first_key = self.identity.identity.get_first_public_key_matching( - Purpose::AUTHENTICATION, - HashSet::from([SecurityLevel::CRITICAL]), - KeyType::all_key_types().into(), - false, - ); - if let Some(key) = first_key { if ui.button("Check Keys").clicked() { + // Clone only on button click, not every frame. + let identity = self.identity.clone().expect("checked above"); action |= AppAction::AddScreen(Screen::KeyInfoScreen(KeyInfoScreen::new( - self.identity.clone(), - key.clone(), + identity, + key, None, &self.app_context, ))); @@ -356,8 +414,10 @@ impl ScreenLike for ClaimTokensScreen { } if ui.button("Add key").clicked() { + // Clone only on button click, not every frame. + let identity = self.identity.clone().expect("checked above"); action |= AppAction::AddScreen(Screen::AddKeyScreen(AddKeyScreen::new( - self.identity.clone(), + identity, &self.app_context, ))); } @@ -365,7 +425,7 @@ impl ScreenLike for ClaimTokensScreen { // Possibly handle locked wallet scenario if let Some(wallet) = &self.selected_wallet { if let Err(e) = try_open_wallet_no_password(wallet) { - self.error_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } if wallet_needs_unlock(wallet) { ui.add_space(10.0); @@ -394,10 +454,12 @@ impl ScreenLike for ClaimTokensScreen { if self.show_advanced_options { ui.heading("1. Select the key to sign the Claim transition"); ui.add_space(10.0); + // Reborrow identity as ref for add_key_chooser; selected_key is &mut self. + let identity = self.identity.as_ref().expect("checked above"); add_key_chooser( ui, &self.app_context, - &self.identity, + identity, &mut self.selected_key, TransactionType::TokenClaim, ); @@ -553,8 +615,11 @@ impl ScreenLike for ClaimTokensScreen { if ui.add(button).clicked() { if self.distribution_type.is_none() { - self.status = ClaimTokensStatus::ErrorMessage( - "Please select a distribution type.".to_string(), + self.status = ClaimTokensStatus::Error; + MessageBanner::set_global( + ui.ctx(), + "Please select a distribution type.", + MessageType::Error, ); return; } else if self.confirmation_dialog.is_none() { @@ -573,33 +638,11 @@ impl ScreenLike for ClaimTokensScreen { ui.add_space(10.0); match &self.status { ClaimTokensStatus::NotStarted => {} - ClaimTokensStatus::WaitingForResult(start_time) => { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let elapsed = now - start_time; - ui.label(format!("Claiming... elapsed: {}s", elapsed)); + ClaimTokensStatus::WaitingForResult => { + // Elapsed display is handled by the global MessageBanner } - ClaimTokensStatus::ErrorMessage(msg) => { - let error_color = DashColors::ERROR; - let msg = msg.clone(); - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label( - RichText::new(format!("Error: {}", msg)).color(error_color), - ); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.status = ClaimTokensStatus::NotStarted; - } - }); - }); + ClaimTokensStatus::Error => { + // Error display is handled by the global MessageBanner } ClaimTokensStatus::Complete => {} } diff --git a/src/ui/tokens/destroy_frozen_funds_screen.rs b/src/ui/tokens/destroy_frozen_funds_screen.rs index 42baa30b3..01732c252 100644 --- a/src/ui/tokens/destroy_frozen_funds_screen.rs +++ b/src/ui/tokens/destroy_frozen_funds_screen.rs @@ -16,11 +16,13 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; +use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt}; use crate::ui::helpers::{TransactionType, add_key_chooser, render_group_action_text}; use crate::ui::identities::get_selected_wallet; use crate::ui::identities::keys::add_key_screen::AddKeyScreen; use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; use crate::ui::theme::DashColors; +use crate::ui::tokens::validate_signing_key; use crate::ui::{MessageType, Screen, ScreenLike}; use dash_sdk::dpp::data_contract::GroupContractPosition; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -37,21 +39,20 @@ use eframe::egui::{self, Color32, Context, Frame, Margin, Ui}; use egui::RichText; use std::collections::HashSet; use std::sync::{Arc, RwLock}; -use std::time::{SystemTime, UNIX_EPOCH}; /// Represents possible states in the “destroy frozen funds” flow #[derive(PartialEq)] pub enum DestroyFrozenFundsStatus { NotStarted, - WaitingForResult(u64), - ErrorMessage(String), + WaitingForResult, + Error, Complete, } /// A screen for destroying frozen funds of a particular token contract pub struct DestroyFrozenFundsScreen { /// Identity that is authorized to destroy - pub identity: QualifiedIdentity, + identity: QualifiedIdentity, /// Info on which token contract we're dealing with pub identity_token_info: IdentityTokenInfo, @@ -73,10 +74,9 @@ pub struct DestroyFrozenFundsScreen { /// All frozen identities that can be selected /// TODO: We should filter them by frozen status, right now we just show all known identities - pub frozen_identities: Vec, + frozen_identities: Vec, status: DestroyFrozenFundsStatus, - error_message: Option, /// Basic references pub app_context: Arc, @@ -89,6 +89,8 @@ pub struct DestroyFrozenFundsScreen { wallet_unlock_popup: WalletUnlockPopup, /// Fee result from completed operation completed_fee_result: Option, + /// Banner handle for elapsed time display + refresh_banner: Option, } impl DestroyFrozenFundsScreen { @@ -104,7 +106,7 @@ impl DestroyFrozenFundsScreen { ) .cloned(); - let mut error_message = None; + let set_error_banner = |msg: &str| super::set_error_banner(app_context, msg); let group = match identity_token_info .token_config @@ -112,32 +114,30 @@ impl DestroyFrozenFundsScreen { .authorized_to_make_change_action_takers() { AuthorizedActionTakers::NoOne => { - error_message = Some("Burning is not allowed on this token".to_string()); + set_error_banner("Destroying frozen funds is not allowed on this token"); None } AuthorizedActionTakers::ContractOwner => { if identity_token_info.data_contract.contract.owner_id() != identity_token_info.identity.identity.id() { - error_message = Some( - "You are not allowed to burn this token. Only the contract owner is." - .to_string(), + set_error_banner( + "You are not allowed to destroy frozen funds on this token. Only the contract owner is.", ); } None } AuthorizedActionTakers::Identity(identifier) => { if identifier != &identity_token_info.identity.identity.id() { - error_message = Some("You are not allowed to burn this token".to_string()); + set_error_banner("You are not allowed to destroy frozen funds on this token"); } None } AuthorizedActionTakers::MainGroup => { match identity_token_info.token_config.main_control_group() { None => { - error_message = Some( - "Invalid contract: No main control group, though one should exist" - .to_string(), + set_error_banner( + "Invalid contract: No main control group, though one should exist", ); None } @@ -149,7 +149,7 @@ impl DestroyFrozenFundsScreen { { Ok(group) => Some((group_pos, group.clone())), Err(e) => { - error_message = Some(format!("Invalid contract: {}", e)); + set_error_banner(&format!("Invalid contract: {}", e)); None } } @@ -164,7 +164,7 @@ impl DestroyFrozenFundsScreen { { Ok(group) => Some((*group_pos, group.clone())), Err(e) => { - error_message = Some(format!("Invalid contract: {}", e)); + set_error_banner(&format!("Invalid contract: {}", e)); None } } @@ -187,16 +187,14 @@ impl DestroyFrozenFundsScreen { }; // Attempt to get an unlocked wallet reference - let selected_wallet = get_selected_wallet( - &identity_token_info.identity, - None, - possible_key.as_ref(), - &mut error_message, - ); + let selected_wallet = + get_selected_wallet(&identity_token_info.identity, None, possible_key.as_ref()) + .unwrap_or_else(|e| { + set_error_banner(&e); + None + }); - let all_identities = app_context - .load_local_qualified_identities() - .expect("Identities not loaded"); + let all_identities = super::load_identities_with_banner(app_context); Self { identity: identity_token_info.identity.clone(), @@ -210,12 +208,12 @@ impl DestroyFrozenFundsScreen { group_action_id: None, public_note: None, status: DestroyFrozenFundsStatus::NotStarted, - error_message, app_context: app_context.clone(), confirmation_dialog: None, selected_wallet, wallet_unlock_popup: WalletUnlockPopup::new(), completed_fee_result: None, + refresh_banner: None, } } @@ -260,35 +258,36 @@ impl DestroyFrozenFundsScreen { } fn confirmation_ok(&mut self) -> AppAction { - let signing_key = match self.selected_key.clone() { - Some(key) => key, - None => { - self.error_message = Some("No signing key selected".into()); - self.status = DestroyFrozenFundsStatus::ErrorMessage("No key selected".into()); - return AppAction::None; - } - }; - - let frozen_id = match Identifier::from_string_try_encodings( + let Ok(frozen_id) = Identifier::from_string_try_encodings( &self.frozen_identity_id, &[ dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58, dash_sdk::dpp::platform_value::string_encoding::Encoding::Hex, ], - ) { - Ok(id) => id, - Err(_) => { - self.error_message = Some("Invalid frozen identity format".into()); - self.status = DestroyFrozenFundsStatus::ErrorMessage("Invalid identity".into()); - return AppAction::None; - } + ) else { + self.status = DestroyFrozenFundsStatus::Error; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Invalid frozen identity format", + MessageType::Error, + ); + return AppAction::None; }; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - self.status = DestroyFrozenFundsStatus::WaitingForResult(now); + // Validate signing key before transitioning to waiting state + let Some(signing_key) = validate_signing_key(&self.app_context, self.selected_key.as_ref()) + else { + return AppAction::None; + }; + + self.status = DestroyFrozenFundsStatus::WaitingForResult; + let handle = MessageBanner::set_global( + self.app_context.egui_ctx(), + "Destroying frozen funds...", + MessageType::Info, + ); + handle.with_elapsed(); + self.refresh_banner = Some(handle); let data_contract = Arc::new(self.identity_token_info.data_contract.contract.clone()); @@ -339,10 +338,11 @@ impl DestroyFrozenFundsScreen { } impl ScreenLike for DestroyFrozenFundsScreen { - fn display_message(&mut self, message: &str, message_type: MessageType) { - if let MessageType::Error = message_type { - self.status = DestroyFrozenFundsStatus::ErrorMessage(message.to_string()); - self.error_message = Some(message.to_string()); + fn display_message(&mut self, _message: &str, message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. + if matches!(message_type, MessageType::Error | MessageType::Warning) { + self.refresh_banner.take_and_clear(); + self.status = DestroyFrozenFundsStatus::Error; } } @@ -350,6 +350,7 @@ impl ScreenLike for DestroyFrozenFundsScreen { if let BackendTaskSuccessResult::DestroyedFrozenFunds(fee_result) = backend_task_success_result { + self.refresh_banner.take_and_clear(); self.completed_fee_result = Some(fee_result); self.status = DestroyFrozenFundsStatus::Complete; } @@ -465,7 +466,7 @@ impl ScreenLike for DestroyFrozenFundsScreen { // Possibly handle locked wallet scenario if let Some(wallet) = &self.selected_wallet { if let Err(e) = try_open_wallet_no_password(wallet) { - self.error_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } if wallet_needs_unlock(wallet) { ui.add_space(10.0); @@ -622,22 +623,11 @@ impl ScreenLike for DestroyFrozenFundsScreen { DestroyFrozenFundsStatus::NotStarted => { // no-op } - DestroyFrozenFundsStatus::WaitingForResult(start_time) => { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let elapsed = now - start_time; - ui.label(format!( - "Destroying frozen funds... elapsed: {} seconds", - elapsed - )); + DestroyFrozenFundsStatus::WaitingForResult => { + // Elapsed display is handled by the global MessageBanner } - DestroyFrozenFundsStatus::ErrorMessage(msg) => { - ui.colored_label( - DashColors::error_color(dark_mode), - format!("Error: {}", msg), - ); + DestroyFrozenFundsStatus::Error => { + // Error display is handled by the global MessageBanner } DestroyFrozenFundsStatus::Complete => { // handled above diff --git a/src/ui/tokens/direct_token_purchase_screen.rs b/src/ui/tokens/direct_token_purchase_screen.rs index 700761815..ebbd63f01 100644 --- a/src/ui/tokens/direct_token_purchase_screen.rs +++ b/src/ui/tokens/direct_token_purchase_screen.rs @@ -1,6 +1,5 @@ use std::collections::HashSet; use std::sync::{Arc, RwLock}; -use std::time::{SystemTime, UNIX_EPOCH}; use dash_sdk::dpp::data_contract::accessors::v1::DataContractV1Getters; use dash_sdk::dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; @@ -27,12 +26,14 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; +use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt}; use crate::ui::components::{Component, ComponentResponse}; use crate::ui::helpers::{TransactionType, add_key_chooser}; use crate::ui::identities::get_selected_wallet; use crate::ui::identities::keys::add_key_screen::AddKeyScreen; use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; use crate::ui::theme::DashColors; +use crate::ui::tokens::validate_signing_key; use crate::ui::{MessageType, Screen, ScreenLike}; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; @@ -42,8 +43,8 @@ use dash_sdk::platform::IdentityPublicKey; #[derive(PartialEq)] pub enum PurchaseTokensStatus { NotStarted, - WaitingForResult(u64), // Use seconds or millis - ErrorMessage(String), + WaitingForResult, + Error, Complete, } @@ -65,13 +66,14 @@ pub struct PurchaseTokenScreen { /// Screen stuff confirmation_dialog: Option, status: PurchaseTokensStatus, - error_message: Option, // Wallet fields selected_wallet: Option>>, wallet_unlock_popup: WalletUnlockPopup, // Fee result from completed operation completed_fee_result: Option, + // Banner handle for elapsed time display + refresh_banner: Option, } impl PurchaseTokenScreen { @@ -87,15 +89,13 @@ impl PurchaseTokenScreen { ) .cloned(); - let mut error_message = None; - // Attempt to get an unlocked wallet reference - let selected_wallet = get_selected_wallet( - &identity_token_info.identity, - None, - possible_key.as_ref(), - &mut error_message, - ); + let selected_wallet = + get_selected_wallet(&identity_token_info.identity, None, possible_key.as_ref()) + .unwrap_or_else(|e| { + MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error); + None + }); Self { identity_token_info, @@ -107,12 +107,12 @@ impl PurchaseTokenScreen { calculated_price_credits: None, pricing_fetch_attempted: false, status: PurchaseTokensStatus::NotStarted, - error_message: None, app_context: app_context.clone(), confirmation_dialog: None, selected_wallet, wallet_unlock_popup: WalletUnlockPopup::new(), completed_fee_result: None, + refresh_banner: None, } } @@ -160,9 +160,11 @@ impl PurchaseTokenScreen { TokenTask::QueryTokenPricing(token_id), ))); } else { - self.error_message = Some("Failed to get token ID from contract".to_string()); - self.status = PurchaseTokensStatus::ErrorMessage( - "Failed to get token ID from contract".to_string(), + self.status = PurchaseTokensStatus::Error; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Failed to get token ID from contract", + MessageType::Error, ); } } @@ -264,16 +266,23 @@ impl PurchaseTokenScreen { /// Renders a confirm popup with the final "Are you sure?" step fn show_confirmation_popup(&mut self, ui: &mut Ui) -> AppAction { let Some(amount) = self.amount_to_purchase_value.as_ref() else { - self.error_message = Some("Please enter a valid amount.".into()); - self.status = PurchaseTokensStatus::ErrorMessage("Invalid amount".into()); + self.status = PurchaseTokensStatus::Error; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Please enter a valid amount.", + MessageType::Error, + ); self.confirmation_dialog = None; return AppAction::None; }; let Some(total_price_credits) = self.calculated_price_credits else { - self.error_message = - Some("Cannot calculate total price. Please fetch token pricing first.".into()); - self.status = PurchaseTokensStatus::ErrorMessage("No pricing fetched".into()); + self.status = PurchaseTokensStatus::Error; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Cannot calculate total price. Please fetch token pricing first.", + MessageType::Error, + ); self.confirmation_dialog = None; return AppAction::None; }; @@ -284,12 +293,21 @@ impl PurchaseTokenScreen { match dialog.show(ui).inner.dialog_response { Some(ConfirmationStatus::Confirmed) => { + let Some(signing_key) = + validate_signing_key(&self.app_context, self.selected_key.as_ref()) + else { + return AppAction::None; + }; + self.confirmation_dialog = None; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - self.status = PurchaseTokensStatus::WaitingForResult(now); + self.status = PurchaseTokensStatus::WaitingForResult; + let handle = MessageBanner::set_global( + self.app_context.egui_ctx(), + "Purchasing tokens...", + MessageType::Info, + ); + handle.with_elapsed(); + self.refresh_banner = Some(handle); AppAction::BackendTasks( vec![ @@ -299,7 +317,7 @@ impl PurchaseTokenScreen { self.identity_token_info.data_contract.contract.clone(), ), token_position: self.identity_token_info.token_position, - signing_key: self.selected_key.clone().expect("Expected a key"), + signing_key, amount: amount.value(), total_agreed_price: total_price_credits, })), @@ -341,17 +359,16 @@ impl ScreenLike for PurchaseTokenScreen { self.status = PurchaseTokensStatus::NotStarted; } else { // No pricing schedule found - token is not for sale - self.status = PurchaseTokensStatus::ErrorMessage( - "This token is not available for direct purchase. No pricing has been set." - .to_string(), - ); - self.error_message = Some( - "This token is not available for direct purchase. No pricing has been set." - .to_string(), + self.status = PurchaseTokensStatus::Error; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "This token is not available for direct purchase. No pricing has been set.", + MessageType::Error, ); } } BackendTaskSuccessResult::PurchasedTokens(fee_result) => { + self.refresh_banner.take_and_clear(); self.completed_fee_result = Some(fee_result); self.status = PurchaseTokensStatus::Complete; } @@ -359,10 +376,11 @@ impl ScreenLike for PurchaseTokenScreen { } } - fn display_message(&mut self, message: &str, message_type: MessageType) { - if let MessageType::Error = message_type { - self.status = PurchaseTokensStatus::ErrorMessage(message.to_string()); - self.error_message = Some(message.to_string()); + fn display_message(&mut self, _message: &str, message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. + if matches!(message_type, MessageType::Error | MessageType::Warning) { + self.refresh_banner.take_and_clear(); + self.status = PurchaseTokensStatus::Error; } } @@ -471,7 +489,7 @@ impl ScreenLike for PurchaseTokenScreen { // Possibly handle locked wallet scenario (similar to TransferTokens) if let Some(wallet) = &self.selected_wallet { if let Err(e) = try_open_wallet_no_password(wallet) { - self.error_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } if wallet_needs_unlock(wallet) { ui.add_space(10.0); @@ -601,12 +619,12 @@ impl ScreenLike for PurchaseTokenScreen { ), )); } else { - self.error_message = Some( - "Cannot calculate total price. Please fetch token pricing first." - .into(), + self.status = PurchaseTokensStatus::Error; + MessageBanner::set_global( + ui.ctx(), + "Cannot calculate total price. Please fetch token pricing first.", + MessageType::Error, ); - self.status = - PurchaseTokensStatus::ErrorMessage("No pricing fetched".into()); } } } else { @@ -636,19 +654,11 @@ impl ScreenLike for PurchaseTokenScreen { PurchaseTokensStatus::NotStarted => { // no-op } - PurchaseTokensStatus::WaitingForResult(start_time) => { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let elapsed = now - start_time; - ui.label(format!("Purchasing... elapsed: {} seconds", elapsed)); + PurchaseTokensStatus::WaitingForResult => { + // Elapsed display is handled by the global MessageBanner } - PurchaseTokensStatus::ErrorMessage(msg) => { - ui.colored_label( - DashColors::error_color(dark_mode), - format!("Error: {}", msg), - ); + PurchaseTokensStatus::Error => { + // Error display is handled by the global MessageBanner } PurchaseTokensStatus::Complete => { // handled above diff --git a/src/ui/tokens/freeze_tokens_screen.rs b/src/ui/tokens/freeze_tokens_screen.rs index 0a45fd075..67f93c977 100644 --- a/src/ui/tokens/freeze_tokens_screen.rs +++ b/src/ui/tokens/freeze_tokens_screen.rs @@ -16,11 +16,13 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; +use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt}; use crate::ui::helpers::{TransactionType, add_key_chooser, render_group_action_text}; use crate::ui::identities::get_selected_wallet; use crate::ui::identities::keys::add_key_screen::AddKeyScreen; use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; use crate::ui::theme::DashColors; +use crate::ui::tokens::validate_signing_key; use crate::ui::{MessageType, Screen, ScreenLike}; use dash_sdk::dpp::data_contract::GroupContractPosition; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -37,20 +39,19 @@ use eframe::egui::{self, Color32, Context, Frame, Margin, Ui}; use egui::RichText; use std::collections::HashSet; use std::sync::{Arc, RwLock}; -use std::time::{SystemTime, UNIX_EPOCH}; /// Internal states for the freeze operation #[derive(PartialEq)] pub enum FreezeTokensStatus { NotStarted, - WaitingForResult(u64), - ErrorMessage(String), + WaitingForResult, + Error, Complete, } /// A UI Screen that allows freezing an identity’s tokens for a particular contract pub struct FreezeTokensScreen { - pub identity: QualifiedIdentity, + identity: QualifiedIdentity, pub identity_token_info: IdentityTokenInfo, selected_key: Option, show_advanced_options: bool, @@ -65,7 +66,6 @@ pub struct FreezeTokensScreen { pub freeze_identity_id: String, status: FreezeTokensStatus, - error_message: Option, // Basic references pub app_context: Arc, @@ -78,13 +78,13 @@ pub struct FreezeTokensScreen { wallet_unlock_popup: WalletUnlockPopup, // Fee result from completed operation completed_fee_result: Option, + // Banner handle for elapsed time display + refresh_banner: Option, } impl FreezeTokensScreen { pub fn new(identity_token_info: IdentityTokenInfo, app_context: &Arc) -> Self { - let known_identities = app_context - .load_local_qualified_identities() - .expect("Identities not loaded"); + let known_identities = super::load_identities_with_banner(app_context); let possible_key = identity_token_info .identity @@ -97,7 +97,7 @@ impl FreezeTokensScreen { ) .cloned(); - let mut error_message = None; + let set_error_banner = |msg: &str| super::set_error_banner(app_context, msg); let group = match identity_token_info .token_config @@ -105,32 +105,30 @@ impl FreezeTokensScreen { .authorized_to_make_change_action_takers() { AuthorizedActionTakers::NoOne => { - error_message = Some("Burning is not allowed on this token".to_string()); + set_error_banner("Freezing is not allowed on this token"); None } AuthorizedActionTakers::ContractOwner => { if identity_token_info.data_contract.contract.owner_id() != identity_token_info.identity.identity.id() { - error_message = Some( - "You are not allowed to burn this token. Only the contract owner is." - .to_string(), + set_error_banner( + "You are not allowed to freeze this token. Only the contract owner is.", ); } None } AuthorizedActionTakers::Identity(identifier) => { if identifier != &identity_token_info.identity.identity.id() { - error_message = Some("You are not allowed to burn this token".to_string()); + set_error_banner("You are not allowed to freeze this token"); } None } AuthorizedActionTakers::MainGroup => { match identity_token_info.token_config.main_control_group() { None => { - error_message = Some( - "Invalid contract: No main control group, though one should exist" - .to_string(), + set_error_banner( + "Invalid contract: No main control group, though one should exist", ); None } @@ -142,7 +140,7 @@ impl FreezeTokensScreen { { Ok(group) => Some((group_pos, group.clone())), Err(e) => { - error_message = Some(format!("Invalid contract: {}", e)); + set_error_banner(&format!("Invalid contract: {}", e)); None } } @@ -157,7 +155,7 @@ impl FreezeTokensScreen { { Ok(group) => Some((*group_pos, group.clone())), Err(e) => { - error_message = Some(format!("Invalid contract: {}", e)); + set_error_banner(&format!("Invalid contract: {}", e)); None } } @@ -180,12 +178,12 @@ impl FreezeTokensScreen { }; // Attempt to get an unlocked wallet reference - let selected_wallet = get_selected_wallet( - &identity_token_info.identity, - None, - possible_key.as_ref(), - &mut error_message, - ); + let selected_wallet = + get_selected_wallet(&identity_token_info.identity, None, possible_key.as_ref()) + .unwrap_or_else(|e| { + set_error_banner(&e); + None + }); Self { identity: identity_token_info.identity.clone(), @@ -198,13 +196,13 @@ impl FreezeTokensScreen { public_note: None, freeze_identity_id: String::new(), status: FreezeTokensStatus::NotStarted, - error_message, app_context: app_context.clone(), confirmation_dialog: None, selected_wallet, wallet_unlock_popup: WalletUnlockPopup::new(), known_identities, completed_fee_result: None, + refresh_banner: None, } } @@ -250,36 +248,37 @@ impl FreezeTokensScreen { /// Handle confirmation OK action fn confirmation_ok(&mut self) -> AppAction { - let signing_key = match self.selected_key.clone() { - Some(key) => key, - None => { - self.error_message = Some("No signing key selected".into()); - self.status = FreezeTokensStatus::ErrorMessage("No key selected".into()); - return AppAction::None; - } - }; - // Validate user input - let freeze_id = match Identifier::from_string_try_encodings( + let Ok(freeze_id) = Identifier::from_string_try_encodings( &self.freeze_identity_id, &[ dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58, dash_sdk::dpp::platform_value::string_encoding::Encoding::Hex, ], - ) { - Ok(id) => id, - Err(_) => { - self.error_message = Some("Please enter a valid identity ID.".into()); - self.status = FreezeTokensStatus::ErrorMessage("Invalid identity".into()); - return AppAction::None; - } + ) else { + self.status = FreezeTokensStatus::Error; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Please enter a valid identity ID.", + MessageType::Error, + ); + return AppAction::None; }; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - self.status = FreezeTokensStatus::WaitingForResult(now); + // Validate signing key before transitioning to waiting state + let Some(signing_key) = validate_signing_key(&self.app_context, self.selected_key.as_ref()) + else { + return AppAction::None; + }; + + self.status = FreezeTokensStatus::WaitingForResult; + let handle = MessageBanner::set_global( + self.app_context.egui_ctx(), + "Freezing tokens...", + MessageType::Info, + ); + handle.with_elapsed(); + self.refresh_banner = Some(handle); // Grab the data contract for this token from the app context let data_contract = Arc::new(self.identity_token_info.data_contract.contract.clone()); @@ -331,15 +330,17 @@ impl FreezeTokensScreen { } impl ScreenLike for FreezeTokensScreen { - fn display_message(&mut self, message: &str, message_type: MessageType) { - if let MessageType::Error = message_type { - self.status = FreezeTokensStatus::ErrorMessage(message.to_string()); - self.error_message = Some(message.to_string()); + fn display_message(&mut self, _message: &str, message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. + if matches!(message_type, MessageType::Error | MessageType::Warning) { + self.refresh_banner.take_and_clear(); + self.status = FreezeTokensStatus::Error; } } fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { if let BackendTaskSuccessResult::FrozeTokens(fee_result) = backend_task_success_result { + self.refresh_banner.take_and_clear(); self.completed_fee_result = Some(fee_result); self.status = FreezeTokensStatus::Complete; } @@ -452,7 +453,7 @@ impl ScreenLike for FreezeTokensScreen { // Possibly handle locked wallet scenario if let Some(wallet) = &self.selected_wallet { if let Err(e) = try_open_wallet_no_password(wallet) { - self.error_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } if wallet_needs_unlock(wallet) { ui.add_space(10.0); @@ -620,33 +621,11 @@ impl ScreenLike for FreezeTokensScreen { FreezeTokensStatus::NotStarted => { // no-op } - FreezeTokensStatus::WaitingForResult(start_time) => { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let elapsed = now - start_time; - ui.label(format!("Freezing... elapsed: {}s", elapsed)); + FreezeTokensStatus::WaitingForResult => { + // Elapsed display is handled by the global MessageBanner } - FreezeTokensStatus::ErrorMessage(msg) => { - let error_color = DashColors::ERROR; - let msg = msg.clone(); - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label( - RichText::new(format!("Error: {}", msg)).color(error_color), - ); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.status = FreezeTokensStatus::NotStarted; - } - }); - }); + FreezeTokensStatus::Error => { + // Error display is handled by the global MessageBanner } FreezeTokensStatus::Complete => { // handled above diff --git a/src/ui/tokens/mint_tokens_screen.rs b/src/ui/tokens/mint_tokens_screen.rs index 782624e72..8fe68fdf5 100644 --- a/src/ui/tokens/mint_tokens_screen.rs +++ b/src/ui/tokens/mint_tokens_screen.rs @@ -7,7 +7,6 @@ use crate::model::amount::Amount; use crate::model::fee_estimation::format_credits_as_dash; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; -use crate::ui::components::MessageBanner; use crate::ui::components::amount_input::AmountInput; use crate::ui::components::component_trait::{Component, ComponentResponse}; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; @@ -19,11 +18,13 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; +use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt}; use crate::ui::helpers::{TransactionType, add_key_chooser, render_group_action_text}; use crate::ui::identities::get_selected_wallet; use crate::ui::identities::keys::add_key_screen::AddKeyScreen; use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; use crate::ui::theme::DashColors; +use crate::ui::tokens::validate_signing_key; use crate::ui::{MessageType, Screen, ScreenLike}; use dash_sdk::dpp::data_contract::GroupContractPosition; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -42,13 +43,11 @@ use eframe::egui::{Frame, Margin}; use egui::RichText; use std::collections::HashSet; use std::sync::{Arc, RwLock}; -use std::time::{SystemTime, UNIX_EPOCH}; - /// Internal states for the mint process. #[derive(PartialEq)] pub enum MintTokensStatus { NotStarted, - WaitingForResult(u64), // Use seconds or millis + WaitingForResult, Error, Complete, } @@ -64,10 +63,10 @@ pub struct MintTokensScreen { pub group_action_id: Option, known_identities: Vec, - pub recipient_identity_id: String, + recipient_identity_id: String, pub amount: Option, - pub amount_input: Option, + amount_input: Option, status: MintTokensStatus, /// Basic references @@ -81,13 +80,13 @@ pub struct MintTokensScreen { wallet_unlock_popup: WalletUnlockPopup, // Fee result from completed operation completed_fee_result: Option, + // Banner handle for elapsed time display + refresh_banner: Option, } impl MintTokensScreen { pub fn new(identity_token_info: IdentityTokenInfo, app_context: &Arc) -> Self { - let known_identities = app_context - .load_local_qualified_identities() - .expect("Identities not loaded"); + let known_identities = super::load_identities_with_banner(app_context); let possible_key = identity_token_info .identity @@ -100,9 +99,7 @@ impl MintTokensScreen { ) .cloned(); - let set_error_banner = |msg: &str| { - MessageBanner::set_global(app_context.egui_ctx(), msg, MessageType::Error); - }; + let set_error_banner = |msg: &str| super::set_error_banner(app_context, msg); let group = match identity_token_info .token_config @@ -183,16 +180,12 @@ impl MintTokensScreen { }; // Attempt to get an unlocked wallet reference - let mut wallet_error = None; - let selected_wallet = get_selected_wallet( - &identity_token_info.identity, - None, - possible_key.as_ref(), - &mut wallet_error, - ); - if let Some(e) = wallet_error { - set_error_banner(&e); - } + let selected_wallet = + get_selected_wallet(&identity_token_info.identity, None, possible_key.as_ref()) + .unwrap_or_else(|e| { + set_error_banner(&e); + None + }); Self { identity_token_info, @@ -212,6 +205,7 @@ impl MintTokensScreen { selected_wallet, wallet_unlock_popup: WalletUnlockPopup::new(), completed_fee_result: None, + refresh_banner: None, } } @@ -226,7 +220,7 @@ impl MintTokensScreen { // Check if input should be disabled when operation is in progress let enabled = match self.status { - MintTokensStatus::WaitingForResult(_) | MintTokensStatus::Complete => false, + MintTokensStatus::WaitingForResult | MintTokensStatus::Complete => false, MintTokensStatus::NotStarted | MintTokensStatus::Error => true, }; @@ -281,19 +275,6 @@ impl MintTokensScreen { } fn confirmation_ok(&mut self) -> AppAction { - let signing_key = match self.selected_key.clone() { - Some(key) => key, - None => { - self.status = MintTokensStatus::Error; - MessageBanner::set_global( - self.app_context.egui_ctx(), - "No signing key selected", - MessageType::Error, - ); - return AppAction::None; - } - }; - if self.amount.is_none() || self.amount == Some(Amount::new(0, 0)) { self.status = MintTokensStatus::Error; MessageBanner::set_global( @@ -304,30 +285,36 @@ impl MintTokensScreen { return AppAction::None; } - let receiver_id = match Identifier::from_string_try_encodings( + let Ok(receiver_id) = Identifier::from_string_try_encodings( &self.recipient_identity_id, &[ dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58, dash_sdk::dpp::platform_value::string_encoding::Encoding::Hex, ], - ) { - Ok(id) => id, - Err(_) => { - self.status = MintTokensStatus::Error; - MessageBanner::set_global( - self.app_context.egui_ctx(), - "Invalid receiver", - MessageType::Error, - ); - return AppAction::None; - } + ) else { + self.status = MintTokensStatus::Error; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Invalid receiver", + MessageType::Error, + ); + return AppAction::None; }; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - self.status = MintTokensStatus::WaitingForResult(now); + // Validate signing key before transitioning to waiting state + let Some(signing_key) = validate_signing_key(&self.app_context, self.selected_key.as_ref()) + else { + return AppAction::None; + }; + + self.status = MintTokensStatus::WaitingForResult; + let handle = MessageBanner::set_global( + self.app_context.egui_ctx(), + "Minting tokens...", + MessageType::Info, + ); + handle.with_elapsed(); + self.refresh_banner = Some(handle); let data_contract = Arc::new(self.identity_token_info.data_contract.contract.clone()); @@ -378,15 +365,16 @@ impl MintTokensScreen { impl ScreenLike for MintTokensScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { - // Global banner is set by AppState before calling display_message; this only updates status. + // Banner display is handled globally by AppState; this is only for side-effects. if matches!(message_type, MessageType::Error | MessageType::Warning) { + self.refresh_banner.take_and_clear(); self.status = MintTokensStatus::Error; } - // Success/Info: no local state change needed; the global banner is the display mechanism. } fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { if let BackendTaskSuccessResult::MintedTokens(fee_result) = backend_task_success_result { + self.refresh_banner.take_and_clear(); self.completed_fee_result = Some(fee_result); self.status = MintTokensStatus::Complete; } @@ -718,16 +706,9 @@ impl ScreenLike for MintTokensScreen { // Show in-progress or error messages ui.add_space(10.0); match &self.status { - MintTokensStatus::NotStarted => { - // no-op - } - MintTokensStatus::WaitingForResult(start_time) => { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let elapsed = now - start_time; - ui.label(format!("Minting... elapsed: {} seconds", elapsed)); + MintTokensStatus::NotStarted => {} + MintTokensStatus::WaitingForResult => { + // Elapsed display is handled by the global MessageBanner } MintTokensStatus::Error => { // Error display is handled by the global MessageBanner diff --git a/src/ui/tokens/mod.rs b/src/ui/tokens/mod.rs index 0143d33d1..66ee31426 100644 --- a/src/ui/tokens/mod.rs +++ b/src/ui/tokens/mod.rs @@ -13,3 +13,47 @@ pub mod transfer_tokens_screen; pub mod unfreeze_tokens_screen; pub mod update_token_config; pub mod view_token_claims_screen; + +use crate::context::AppContext; +use crate::model::qualified_identity::QualifiedIdentity; +use crate::ui::MessageType; +use crate::ui::components::MessageBanner; +use dash_sdk::platform::IdentityPublicKey; + +/// Loads local identities, displaying an error banner on failure. +pub fn load_identities_with_banner(app_context: &AppContext) -> Vec { + use crate::ui::components::ResultBannerExt; + app_context + .load_local_qualified_identities() + .or_show_error(app_context.egui_ctx()) + .unwrap_or_default() +} + +/// Convenience wrapper for setting an error banner from a screen constructor. +/// +/// Used by token screen constructors to report configuration errors +/// (e.g., "Burning is not allowed on this token") during initialization. +pub fn set_error_banner(app_context: &AppContext, msg: &str) { + MessageBanner::set_global(app_context.egui_ctx(), msg, MessageType::Error); +} + +/// Validates that a signing key is selected before dispatching a backend task. +/// +/// Returns the signing key on success, or sets a global error banner and returns +/// `None` so callers can bail out early with `let Some(key) = ... else { return; }`. +pub fn validate_signing_key( + app_context: &AppContext, + selected_key: Option<&IdentityPublicKey>, +) -> Option { + match selected_key { + Some(key) => Some(key.clone()), + None => { + MessageBanner::set_global( + app_context.egui_ctx(), + "No signing key selected", + MessageType::Error, + ); + None + } + } +} diff --git a/src/ui/tokens/pause_tokens_screen.rs b/src/ui/tokens/pause_tokens_screen.rs index 5e7d023ac..1af503fed 100644 --- a/src/ui/tokens/pause_tokens_screen.rs +++ b/src/ui/tokens/pause_tokens_screen.rs @@ -15,11 +15,13 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; +use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt}; use crate::ui::helpers::{TransactionType, add_key_chooser, render_group_action_text}; use crate::ui::identities::get_selected_wallet; use crate::ui::identities::keys::add_key_screen::AddKeyScreen; use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; use crate::ui::theme::DashColors; +use crate::ui::tokens::validate_signing_key; use crate::ui::{MessageType, Screen, ScreenLike}; use dash_sdk::dpp::data_contract::GroupContractPosition; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -36,20 +38,19 @@ use eframe::egui::{self, Color32, Context, Frame, Margin, Ui}; use egui::RichText; use std::collections::HashSet; use std::sync::{Arc, RwLock}; -use std::time::{SystemTime, UNIX_EPOCH}; /// Represents states for the pause flow #[derive(PartialEq)] pub enum PauseTokensStatus { NotStarted, - WaitingForResult(u64), - ErrorMessage(String), + WaitingForResult, + Error, Complete, } /// A UI screen that allows pausing all token-related actions for a contract pub struct PauseTokensScreen { - pub identity: QualifiedIdentity, + identity: QualifiedIdentity, pub identity_token_info: IdentityTokenInfo, selected_key: Option, show_advanced_options: bool, @@ -59,7 +60,6 @@ pub struct PauseTokensScreen { pub public_note: Option, status: PauseTokensStatus, - error_message: Option, // Basic references pub app_context: Arc, @@ -72,6 +72,8 @@ pub struct PauseTokensScreen { wallet_unlock_popup: WalletUnlockPopup, // Fee result from completed operation completed_fee_result: Option, + // Banner handle for elapsed time display + refresh_banner: Option, } impl PauseTokensScreen { @@ -87,7 +89,7 @@ impl PauseTokensScreen { ) .cloned(); - let mut error_message = None; + let set_error_banner = |msg: &str| super::set_error_banner(app_context, msg); let group = match identity_token_info .token_config @@ -95,32 +97,30 @@ impl PauseTokensScreen { .authorized_to_make_change_action_takers() { AuthorizedActionTakers::NoOne => { - error_message = Some("Burning is not allowed on this token".to_string()); + set_error_banner("Pausing is not allowed on this token"); None } AuthorizedActionTakers::ContractOwner => { if identity_token_info.data_contract.contract.owner_id() != identity_token_info.identity.identity.id() { - error_message = Some( - "You are not allowed to burn this token. Only the contract owner is." - .to_string(), + set_error_banner( + "You are not allowed to pause this token. Only the contract owner is.", ); } None } AuthorizedActionTakers::Identity(identifier) => { if identifier != &identity_token_info.identity.identity.id() { - error_message = Some("You are not allowed to burn this token".to_string()); + set_error_banner("You are not allowed to pause this token"); } None } AuthorizedActionTakers::MainGroup => { match identity_token_info.token_config.main_control_group() { None => { - error_message = Some( - "Invalid contract: No main control group, though one should exist" - .to_string(), + set_error_banner( + "Invalid contract: No main control group, though one should exist", ); None } @@ -132,7 +132,7 @@ impl PauseTokensScreen { { Ok(group) => Some((group_pos, group.clone())), Err(e) => { - error_message = Some(format!("Invalid contract: {}", e)); + set_error_banner(&format!("Invalid contract: {}", e)); None } } @@ -147,7 +147,7 @@ impl PauseTokensScreen { { Ok(group) => Some((*group_pos, group.clone())), Err(e) => { - error_message = Some(format!("Invalid contract: {}", e)); + set_error_banner(&format!("Invalid contract: {}", e)); None } } @@ -170,12 +170,12 @@ impl PauseTokensScreen { }; // Attempt to get an unlocked wallet reference - let selected_wallet = get_selected_wallet( - &identity_token_info.identity, - None, - possible_key.as_ref(), - &mut error_message, - ); + let selected_wallet = + get_selected_wallet(&identity_token_info.identity, None, possible_key.as_ref()) + .unwrap_or_else(|e| { + set_error_banner(&e); + None + }); Self { identity: identity_token_info.identity.clone(), @@ -187,12 +187,12 @@ impl PauseTokensScreen { group_action_id: None, public_note: None, status: PauseTokensStatus::NotStarted, - error_message, app_context: app_context.clone(), confirmation_dialog: None, selected_wallet, wallet_unlock_popup: WalletUnlockPopup::new(), completed_fee_result: None, + refresh_banner: None, } } @@ -208,20 +208,21 @@ impl PauseTokensScreen { Some(ConfirmationStatus::Confirmed) => { self.confirmation_dialog = None; - let signing_key = match self.selected_key.clone() { - Some(key) => key, - None => { - self.error_message = Some("No signing key selected".into()); - self.status = PauseTokensStatus::ErrorMessage("No key selected".into()); - return AppAction::None; - } + // Validate signing key before transitioning to waiting state + let Some(signing_key) = + validate_signing_key(&self.app_context, self.selected_key.as_ref()) + else { + return AppAction::None; }; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - self.status = PauseTokensStatus::WaitingForResult(now); + self.status = PauseTokensStatus::WaitingForResult; + let handle = MessageBanner::set_global( + self.app_context.egui_ctx(), + "Pausing tokens...", + MessageType::Info, + ); + handle.with_elapsed(); + self.refresh_banner = Some(handle); // Grab the data contract for this token from the app context let data_contract = @@ -278,15 +279,17 @@ impl PauseTokensScreen { } impl ScreenLike for PauseTokensScreen { - fn display_message(&mut self, message: &str, message_type: MessageType) { - if let MessageType::Error = message_type { - self.status = PauseTokensStatus::ErrorMessage(message.to_string()); - self.error_message = Some(message.to_string()); + fn display_message(&mut self, _message: &str, message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. + if matches!(message_type, MessageType::Error | MessageType::Warning) { + self.refresh_banner.take_and_clear(); + self.status = PauseTokensStatus::Error; } } fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { if let BackendTaskSuccessResult::PausedTokens(fee_result) = backend_task_success_result { + self.refresh_banner.take_and_clear(); self.completed_fee_result = Some(fee_result); self.status = PauseTokensStatus::Complete; } @@ -397,7 +400,7 @@ impl ScreenLike for PauseTokensScreen { // Possibly handle locked wallet scenario if let Some(wallet) = &self.selected_wallet { if let Err(e) = try_open_wallet_no_password(wallet) { - self.error_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } if wallet_needs_unlock(wallet) { ui.add_space(10.0); @@ -525,33 +528,11 @@ impl ScreenLike for PauseTokensScreen { ui.add_space(10.0); match &self.status { PauseTokensStatus::NotStarted => {} - PauseTokensStatus::WaitingForResult(start_time) => { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let elapsed = now - start_time; - ui.label(format!("Pausing... elapsed: {}s", elapsed)); + PauseTokensStatus::WaitingForResult => { + // Elapsed display is handled by the global MessageBanner } - PauseTokensStatus::ErrorMessage(msg) => { - let error_color = DashColors::ERROR; - let msg = msg.clone(); - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label( - RichText::new(format!("Error: {}", msg)).color(error_color), - ); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.status = PauseTokensStatus::NotStarted; - } - }); - }); + PauseTokensStatus::Error => { + // Error display is handled by the global MessageBanner } PauseTokensStatus::Complete => {} } diff --git a/src/ui/tokens/resume_tokens_screen.rs b/src/ui/tokens/resume_tokens_screen.rs index b99686160..ebd41cec1 100644 --- a/src/ui/tokens/resume_tokens_screen.rs +++ b/src/ui/tokens/resume_tokens_screen.rs @@ -15,11 +15,13 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; +use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt}; use crate::ui::helpers::{TransactionType, add_key_chooser, render_group_action_text}; use crate::ui::identities::get_selected_wallet; use crate::ui::identities::keys::add_key_screen::AddKeyScreen; use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; use crate::ui::theme::DashColors; +use crate::ui::tokens::validate_signing_key; use crate::ui::{MessageType, Screen, ScreenLike}; use dash_sdk::dpp::data_contract::GroupContractPosition; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -36,19 +38,18 @@ use eframe::egui::{self, Color32, Context, Frame, Margin, Ui}; use egui::RichText; use std::collections::HashSet; use std::sync::{Arc, RwLock}; -use std::time::{SystemTime, UNIX_EPOCH}; /// States for the resume flow #[derive(PartialEq)] pub enum ResumeTokensStatus { NotStarted, - WaitingForResult(u64), - ErrorMessage(String), + WaitingForResult, + Error, Complete, } pub struct ResumeTokensScreen { - pub identity: QualifiedIdentity, + identity: QualifiedIdentity, pub identity_token_info: IdentityTokenInfo, selected_key: Option, show_advanced_options: bool, @@ -58,7 +59,6 @@ pub struct ResumeTokensScreen { pub public_note: Option, status: ResumeTokensStatus, - error_message: Option, // Basic references pub app_context: Arc, @@ -71,6 +71,8 @@ pub struct ResumeTokensScreen { wallet_unlock_popup: WalletUnlockPopup, // Fee result from completed operation completed_fee_result: Option, + // Banner handle for elapsed time display + refresh_banner: Option, } impl ResumeTokensScreen { @@ -86,7 +88,7 @@ impl ResumeTokensScreen { ) .cloned(); - let mut error_message = None; + let set_error_banner = |msg: &str| super::set_error_banner(app_context, msg); let group = match identity_token_info .token_config @@ -94,32 +96,30 @@ impl ResumeTokensScreen { .authorized_to_make_change_action_takers() { AuthorizedActionTakers::NoOne => { - error_message = Some("Burning is not allowed on this token".to_string()); + set_error_banner("Resuming is not allowed on this token"); None } AuthorizedActionTakers::ContractOwner => { if identity_token_info.data_contract.contract.owner_id() != identity_token_info.identity.identity.id() { - error_message = Some( - "You are not allowed to burn this token. Only the contract owner is." - .to_string(), + set_error_banner( + "You are not allowed to resume this token. Only the contract owner is.", ); } None } AuthorizedActionTakers::Identity(identifier) => { if identifier != &identity_token_info.identity.identity.id() { - error_message = Some("You are not allowed to burn this token".to_string()); + set_error_banner("You are not allowed to resume this token"); } None } AuthorizedActionTakers::MainGroup => { match identity_token_info.token_config.main_control_group() { None => { - error_message = Some( - "Invalid contract: No main control group, though one should exist" - .to_string(), + set_error_banner( + "Invalid contract: No main control group, though one should exist", ); None } @@ -131,7 +131,7 @@ impl ResumeTokensScreen { { Ok(group) => Some((group_pos, group.clone())), Err(e) => { - error_message = Some(format!("Invalid contract: {}", e)); + set_error_banner(&format!("Invalid contract: {}", e)); None } } @@ -146,7 +146,7 @@ impl ResumeTokensScreen { { Ok(group) => Some((*group_pos, group.clone())), Err(e) => { - error_message = Some(format!("Invalid contract: {}", e)); + set_error_banner(&format!("Invalid contract: {}", e)); None } } @@ -169,12 +169,12 @@ impl ResumeTokensScreen { }; // Attempt to get an unlocked wallet reference - let selected_wallet = get_selected_wallet( - &identity_token_info.identity, - None, - possible_key.as_ref(), - &mut error_message, - ); + let selected_wallet = + get_selected_wallet(&identity_token_info.identity, None, possible_key.as_ref()) + .unwrap_or_else(|e| { + set_error_banner(&e); + None + }); Self { identity: identity_token_info.identity.clone(), @@ -186,12 +186,12 @@ impl ResumeTokensScreen { group_action_id: None, public_note: None, status: ResumeTokensStatus::NotStarted, - error_message, app_context: app_context.clone(), confirmation_dialog: None, selected_wallet, wallet_unlock_popup: WalletUnlockPopup::new(), completed_fee_result: None, + refresh_banner: None, } } @@ -208,20 +208,21 @@ impl ResumeTokensScreen { Some(ConfirmationStatus::Confirmed) => { self.confirmation_dialog = None; - let signing_key = match self.selected_key.clone() { - Some(key) => key, - None => { - self.error_message = Some("No signing key selected".into()); - self.status = ResumeTokensStatus::ErrorMessage("No key selected".into()); - return AppAction::None; - } + // Validate signing key before transitioning to waiting state + let Some(signing_key) = + validate_signing_key(&self.app_context, self.selected_key.as_ref()) + else { + return AppAction::None; }; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - self.status = ResumeTokensStatus::WaitingForResult(now); + self.status = ResumeTokensStatus::WaitingForResult; + let handle = MessageBanner::set_global( + self.app_context.egui_ctx(), + "Resuming tokens...", + MessageType::Info, + ); + handle.with_elapsed(); + self.refresh_banner = Some(handle); // Grab the data contract for this token from the app context let data_contract = @@ -278,15 +279,17 @@ impl ResumeTokensScreen { } impl ScreenLike for ResumeTokensScreen { - fn display_message(&mut self, message: &str, message_type: MessageType) { - if let MessageType::Error = message_type { - self.status = ResumeTokensStatus::ErrorMessage(message.to_string()); - self.error_message = Some(message.to_string()); + fn display_message(&mut self, _message: &str, message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. + if matches!(message_type, MessageType::Error | MessageType::Warning) { + self.refresh_banner.take_and_clear(); + self.status = ResumeTokensStatus::Error; } } fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { if let BackendTaskSuccessResult::ResumedTokens(fee_result) = backend_task_success_result { + self.refresh_banner.take_and_clear(); self.completed_fee_result = Some(fee_result); self.status = ResumeTokensStatus::Complete; } @@ -398,7 +401,7 @@ impl ScreenLike for ResumeTokensScreen { // Possibly handle locked wallet scenario if let Some(wallet) = &self.selected_wallet { if let Err(e) = try_open_wallet_no_password(wallet) { - self.error_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } if wallet_needs_unlock(wallet) { ui.add_space(10.0); @@ -525,33 +528,11 @@ impl ScreenLike for ResumeTokensScreen { ui.add_space(10.0); match &self.status { ResumeTokensStatus::NotStarted => {} - ResumeTokensStatus::WaitingForResult(start_time) => { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let elapsed = now - start_time; - ui.label(format!("Resuming... elapsed: {}s", elapsed)); + ResumeTokensStatus::WaitingForResult => { + // Elapsed display is handled by the global MessageBanner } - ResumeTokensStatus::ErrorMessage(msg) => { - let error_color = DashColors::ERROR; - let msg = msg.clone(); - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label( - RichText::new(format!("Error: {}", msg)).color(error_color), - ); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.status = ResumeTokensStatus::NotStarted; - } - }); - }); + ResumeTokensStatus::Error => { + // Error display is handled by the global MessageBanner } ResumeTokensStatus::Complete => {} } diff --git a/src/ui/tokens/set_token_price_screen.rs b/src/ui/tokens/set_token_price_screen.rs index 6e86365b2..364008658 100644 --- a/src/ui/tokens/set_token_price_screen.rs +++ b/src/ui/tokens/set_token_price_screen.rs @@ -17,11 +17,13 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; +use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt}; use crate::ui::helpers::{TransactionType, add_key_chooser}; use crate::ui::identities::get_selected_wallet; use crate::ui::identities::keys::add_key_screen::AddKeyScreen; use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; use crate::ui::theme::DashColors; +use crate::ui::tokens::validate_signing_key; use crate::ui::{MessageType, Screen, ScreenLike}; use dash_sdk::dpp::balances::credits::Credits; use dash_sdk::dpp::data_contract::GroupContractPosition; @@ -43,7 +45,6 @@ use egui::RichText; use egui_extras::{Column, TableBuilder}; use std::collections::HashSet; use std::sync::{Arc, RwLock}; -use std::time::{SystemTime, UNIX_EPOCH}; /// Pricing type selection #[derive(PartialEq, Clone)] @@ -75,8 +76,8 @@ impl From> for PricingType { #[derive(PartialEq)] pub enum SetTokenPriceStatus { NotStarted, - WaitingForResult(u64), // Use seconds or millis - ErrorMessage(String), + WaitingForResult, + Error, Complete, } @@ -92,16 +93,15 @@ pub struct SetTokenPriceScreen { pub token_pricing_schedule: String, /// Token pricing schedule to use; if None, we will remove the pricing schedule - pub pricing_type: PricingType, + pricing_type: PricingType, // AmountInput components for pricing - following the design pattern single_price_amount: Option, single_price_input: Option, // Tiered pricing with AmountInput components - pub tiered_prices: Vec<(Option, Option)>, // (amount_input, price_input) + tiered_prices: Vec<(Option, Option)>, // (amount_input, price_input) status: SetTokenPriceStatus, - error_message: Option, /// Basic references pub app_context: Arc, @@ -115,6 +115,8 @@ pub struct SetTokenPriceScreen { wallet_unlock_popup: WalletUnlockPopup, // Fee result from completed operation completed_fee_result: Option, + // Banner handle for elapsed time display + refresh_banner: Option, } /// 1 Dash = 100,000,000,000 credits @@ -170,7 +172,7 @@ impl SetTokenPriceScreen { false, ); - let mut error_message = None; + let set_error_banner = |msg: &str| super::set_error_banner(app_context, msg); let group = match identity_token_info .token_config @@ -179,34 +181,30 @@ impl SetTokenPriceScreen { .authorized_to_make_change_action_takers() { AuthorizedActionTakers::NoOne => { - error_message = - Some("Setting token price is not allowed on this token".to_string()); + set_error_banner("Setting token price is not allowed on this token"); None } AuthorizedActionTakers::ContractOwner => { if identity_token_info.data_contract.contract.owner_id() != identity_token_info.identity.identity.id() { - error_message = Some( - "You are not allowed to set token price on this token. Only the contract owner is." - .to_string(), + set_error_banner( + "You are not allowed to set token price on this token. Only the contract owner is.", ); } None } AuthorizedActionTakers::Identity(identifier) => { if identifier != &identity_token_info.identity.identity.id() { - error_message = - Some("You are not allowed to set token price on this token".to_string()); + set_error_banner("You are not allowed to set token price on this token"); } None } AuthorizedActionTakers::MainGroup => { match identity_token_info.token_config.main_control_group() { None => { - error_message = Some( - "Invalid contract: No main control group, though one should exist" - .to_string(), + set_error_banner( + "Invalid contract: No main control group, though one should exist", ); None } @@ -218,7 +216,7 @@ impl SetTokenPriceScreen { { Ok(group) => Some((group_pos, group.clone())), Err(e) => { - error_message = Some(format!("Invalid contract: {}", e)); + set_error_banner(&format!("Invalid contract: {}", e)); None } } @@ -233,7 +231,7 @@ impl SetTokenPriceScreen { { Ok(group) => Some((*group_pos, group.clone())), Err(e) => { - error_message = Some(format!("Invalid contract: {}", e)); + set_error_banner(&format!("Invalid contract: {}", e)); None } } @@ -256,12 +254,13 @@ impl SetTokenPriceScreen { }; // Attempt to get an unlocked wallet reference - let selected_wallet = get_selected_wallet( - &identity_token_info.identity, - None, - possible_key, - &mut error_message, - ); + let selected_wallet = + get_selected_wallet(&identity_token_info.identity, None, possible_key).unwrap_or_else( + |e| { + set_error_banner(&e); + None + }, + ); Self { identity_token_info: identity_token_info.clone(), @@ -277,13 +276,13 @@ impl SetTokenPriceScreen { single_price_input: None, tiered_prices: vec![(None, None)], status: SetTokenPriceStatus::NotStarted, - error_message: None, app_context: app_context.clone(), show_confirmation_popup: false, confirmation_dialog: None, selected_wallet, wallet_unlock_popup: WalletUnlockPopup::new(), completed_fee_result: None, + refresh_banner: None, } } @@ -725,20 +724,29 @@ impl SetTokenPriceScreen { } }; + // Validate signing key before transitioning to waiting state + let Some(signing_key) = validate_signing_key(&self.app_context, self.selected_key.as_ref()) + else { + return AppAction::None; + }; + // Set waiting state - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - self.status = SetTokenPriceStatus::WaitingForResult(now); + self.status = SetTokenPriceStatus::WaitingForResult; + let handle = MessageBanner::set_global( + self.app_context.egui_ctx(), + "Setting token price...", + MessageType::Info, + ); + handle.with_elapsed(); + self.refresh_banner = Some(handle); // Prepare group info - let group_info = if self.group_action_id.is_some() { + let group_info = if let Some(action_id) = self.group_action_id { self.group.as_ref().map(|(pos, _)| { GroupStateTransitionInfoStatus::GroupStateTransitionInfoOtherSigner( GroupStateTransitionInfo { group_contract_position: *pos, - action_id: self.group_action_id.unwrap(), + action_id, action_is_proposer: false, }, ) @@ -755,7 +763,7 @@ impl SetTokenPriceScreen { identity: self.identity_token_info.identity.clone(), data_contract: Arc::new(self.identity_token_info.data_contract.contract.clone()), token_position: self.identity_token_info.token_position, - signing_key: self.selected_key.clone().expect("Expected a key"), + signing_key, public_note: if self.group_action_id.is_some() { None } else { @@ -776,8 +784,8 @@ impl SetTokenPriceScreen { /// Set error state with the given message fn set_error_state(&mut self, error: String) { - self.error_message = Some(error.clone()); - self.status = SetTokenPriceStatus::ErrorMessage(error); + self.status = SetTokenPriceStatus::Error; + MessageBanner::set_global(self.app_context.egui_ctx(), &error, MessageType::Error); } /// Renders a confirm popup with the final "Are you sure?" step @@ -818,15 +826,17 @@ impl SetTokenPriceScreen { } impl ScreenLike for SetTokenPriceScreen { - fn display_message(&mut self, message: &str, message_type: MessageType) { - if let MessageType::Error = message_type { - self.status = SetTokenPriceStatus::ErrorMessage(message.to_string()); - self.error_message = Some(message.to_string()); + fn display_message(&mut self, _message: &str, message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. + if matches!(message_type, MessageType::Error | MessageType::Warning) { + self.refresh_banner.take_and_clear(); + self.status = SetTokenPriceStatus::Error; } } fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { if let BackendTaskSuccessResult::SetTokenPrice(fee_result) = backend_task_success_result { + self.refresh_banner.take_and_clear(); self.completed_fee_result = Some(fee_result); self.status = SetTokenPriceStatus::Complete; } @@ -952,7 +962,7 @@ impl ScreenLike for SetTokenPriceScreen { // Possibly handle locked wallet scenario (similar to TransferTokens) if let Some(wallet) = &self.selected_wallet { if let Err(e) = try_open_wallet_no_password(wallet) { - self.error_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } if wallet_needs_unlock(wallet) { ui.add_space(10.0); @@ -1046,8 +1056,11 @@ impl ScreenLike for SetTokenPriceScreen { .members() .get(&self.identity_token_info.identity.identity.id()); if your_power.is_none() { - self.error_message = - Some("Only group members can set price on this token".to_string()); + MessageBanner::set_global( + ui.ctx(), + "Only group members can set price on this token", + MessageType::Error, + ); } ui.heading("This is a group action, it is not immediate."); ui.label(format!( @@ -1109,7 +1122,7 @@ impl ScreenLike for SetTokenPriceScreen { // Set price button let validation_result = self.validate_pricing_configuration(); - let button_active = validation_result.is_ok() && !matches!(self.status, SetTokenPriceStatus::WaitingForResult(_)); + let button_active = validation_result.is_ok() && !matches!(self.status, SetTokenPriceStatus::WaitingForResult); let button_color = if validation_result.is_ok() { DashColors::ACTION_BUTTON_BLUE @@ -1140,31 +1153,11 @@ impl ScreenLike for SetTokenPriceScreen { SetTokenPriceStatus::NotStarted => { // no-op } - SetTokenPriceStatus::WaitingForResult(start_time) => { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let elapsed = now - start_time; - ui.label(format!("Setting price... elapsed: {} seconds", elapsed)); + SetTokenPriceStatus::WaitingForResult => { + // Elapsed display is handled by the global MessageBanner } - SetTokenPriceStatus::ErrorMessage(msg) => { - let error_color = DashColors::ERROR; - let msg = msg.clone(); - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label(RichText::new(format!("Error: {}", msg)).color(error_color)); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.status = SetTokenPriceStatus::NotStarted; - } - }); - }); + SetTokenPriceStatus::Error => { + // Error display is handled by the global MessageBanner } SetTokenPriceStatus::Complete => { // handled above diff --git a/src/ui/tokens/tokens_screen/contract_details.rs b/src/ui/tokens/tokens_screen/contract_details.rs index 9f96d00dc..f315e87d0 100644 --- a/src/ui/tokens/tokens_screen/contract_details.rs +++ b/src/ui/tokens/tokens_screen/contract_details.rs @@ -1,3 +1,5 @@ +use crate::ui::MessageType; +use crate::ui::components::MessageBanner; use crate::ui::tokens::tokens_screen::TokensScreen; use crate::{app::AppAction, ui::theme::DashColors}; use dash_sdk::dpp::platform_value::string_encoding::Encoding; @@ -85,7 +87,7 @@ impl TokensScreen { action |= internal_action; } Err(e) => { - self.token_creator_error_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } } } @@ -96,7 +98,11 @@ impl TokensScreen { self.json_popup_text = schema; } Err(e) => { - self.token_creator_error_message = Some(e.to_string()); + MessageBanner::set_global( + ui.ctx(), + e.to_string(), + MessageType::Error, + ); } } } diff --git a/src/ui/tokens/tokens_screen/keyword_search.rs b/src/ui/tokens/tokens_screen/keyword_search.rs index 1879675f1..5a1109b09 100644 --- a/src/ui/tokens/tokens_screen/keyword_search.rs +++ b/src/ui/tokens/tokens_screen/keyword_search.rs @@ -2,14 +2,15 @@ use crate::app::AppAction; use crate::backend_task::BackendTask; use crate::backend_task::contract::ContractTask; use crate::backend_task::tokens::TokenTask; +use crate::ui::MessageType; +use crate::ui::components::MessageBanner; use crate::ui::theme::DashColors; use crate::ui::tokens::tokens_screen::{ ContractDescriptionInfo, ContractSearchStatus, TokensScreen, }; -use chrono::Utc; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use eframe::emath::Align; -use egui::{Frame, Margin, RichText, Ui}; +use egui::{RichText, Ui}; use egui_extras::{Column, TableBuilder}; const KEYWORD_SEARCH_INFO_TEXT: &str = "Keyword Search allows you to find tokens by searching their associated keywords.\n\n\ @@ -59,8 +60,17 @@ impl TokensScreen { if go_clicked || enter_pressed { // Clear old results, set status self.search_results.lock().unwrap().clear(); - let now = Utc::now().timestamp() as u64; - self.contract_search_status = ContractSearchStatus::WaitingForResult(now); + self.contract_search_status = ContractSearchStatus::WaitingForResult; + if let Some(h) = self.operation_banner.take() { + h.clear(); + } + let handle = MessageBanner::set_global( + ui.ctx(), + "Searching contracts...", + MessageType::Info, + ); + handle.with_elapsed(); + self.operation_banner = Some(handle); self.search_current_page = 1; self.next_cursors.clear(); self.previous_cursors.clear(); @@ -103,13 +113,8 @@ impl TokensScreen { ContractSearchStatus::NotStarted => { // Nothing } - ContractSearchStatus::WaitingForResult(start_time) => { - let now = Utc::now().timestamp() as u64; - let elapsed = now - start_time; - ui.horizontal(|ui| { - ui.label(format!("Searching... {} seconds", elapsed)); - ui.add(egui::widgets::Spinner::default().color(DashColors::DASH_BLUE)); - }); + ContractSearchStatus::WaitingForResult => { + // Elapsed display is handled by the global MessageBanner } ContractSearchStatus::Complete => { // Show the results @@ -138,23 +143,8 @@ impl TokensScreen { } } } - ContractSearchStatus::ErrorMessage(e) => { - let error_color = DashColors::error_color(ui.visuals().dark_mode); - let msg = e.clone(); - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label(RichText::new(format!("Error: {}", msg)).color(error_color)); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.contract_search_status = ContractSearchStatus::NotStarted; - } - }); - }); + ContractSearchStatus::Error => { + // Error message is displayed by the global MessageBanner } } diff --git a/src/ui/tokens/tokens_screen/mod.rs b/src/ui/tokens/tokens_screen/mod.rs index 4c5f737a9..4c1cc3fe2 100644 --- a/src/ui/tokens/tokens_screen/mod.rs +++ b/src/ui/tokens/tokens_screen/mod.rs @@ -60,10 +60,12 @@ use crate::context::AppContext; use crate::model::amount::Amount; use crate::model::qualified_identity::{IdentityType, QualifiedIdentity}; use crate::model::wallet::Wallet; +use crate::ui::components::MessageBanner; use crate::ui::components::amount_input::AmountInput; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::info_popup::InfoPopup; use crate::ui::components::left_panel::add_left_panel; +use crate::ui::components::message_banner::{BannerHandle, OptionBannerExt}; use crate::ui::components::styled::island_central_panel; use crate::ui::components::tokens_subscreen_chooser_panel::add_tokens_subscreen_chooser_panel; use crate::ui::components::top_panel::add_top_panel; @@ -184,26 +186,26 @@ impl TokensSubscreen { #[derive(PartialEq)] pub enum RefreshingStatus { - Refreshing(u64), + Refreshing, NotRefreshing, } -/// Represents the status of the user’s search +/// Represents the status of the user's search #[derive(PartialEq, Eq, Clone)] pub enum ContractSearchStatus { NotStarted, - WaitingForResult(u64), + WaitingForResult, Complete, - ErrorMessage(String), + Error, } #[derive(Debug, PartialEq, Default)] pub enum TokenCreatorStatus { #[default] NotStarted, - WaitingForResult(u64), + WaitingForResult, Complete, - ErrorMessage(String), + Error, } /// Sorting columns @@ -1167,7 +1169,6 @@ pub struct TokensScreen { Option, >, pricing_loading_state: IndexMap, - backend_message: Option<(String, MessageType, DateTime)>, pending_backend_task: Option, refreshing_status: RefreshingStatus, should_reset_collapsing_states: bool, @@ -1255,7 +1256,6 @@ pub struct TokensScreen { show_token_creator_confirmation_popup: bool, token_creator_confirmation_dialog: Option, token_creator_status: TokenCreatorStatus, - token_creator_error_message: Option, show_advanced_keeps_history: bool, token_advanced_keeps_history: TokenKeepsHistoryRulesV0, groups_ui: Vec, @@ -1385,6 +1385,9 @@ pub struct TokensScreen { adding_token_start_time: Option>, adding_token_name: Option, + // Banner handle for elapsed-time progress display + operation_banner: Option, + // Document Schemas document_schemas_input: String, parsed_document_schemas: Option>, @@ -1564,7 +1567,6 @@ impl TokensScreen { next_cursors: vec![], previous_cursors: vec![], search_results: Arc::new(Mutex::new(Vec::new())), - backend_message: None, sort_column: SortColumn::OwnerIdentityAlias, sort_order: SortOrder::Ascending, use_custom_order: false, @@ -1597,7 +1599,6 @@ impl TokensScreen { show_token_creator_confirmation_popup: false, token_creator_confirmation_dialog: None, token_creator_status: TokenCreatorStatus::NotStarted, - token_creator_error_message: None, token_names_input: vec![( String::new(), String::new(), @@ -1762,6 +1763,9 @@ impl TokensScreen { adding_token_start_time: None, adding_token_name: None, + // Banner handle for elapsed-time progress display + operation_banner: None, + // Document Schemas document_schemas_input: String::new(), parsed_document_schemas: None, @@ -1844,19 +1848,7 @@ impl TokensScreen { // Message handling // ───────────────────────────────────────────────────────────────── - fn dismiss_message(&mut self) { - self.backend_message = None; - } - - fn check_error_expiration(&mut self) { - if let Some((_, _, timestamp)) = &self.backend_message { - let now = Utc::now(); - let elapsed = now.signed_duration_since(*timestamp); - if elapsed.num_seconds() >= 10 { - self.dismiss_message(); - } - } - } + // Message display is handled by the global MessageBanner fn history_row(&mut self, ui: &mut Ui) { // --- 1. pull or create the rules object -------------------------------- @@ -2249,9 +2241,10 @@ impl TokensScreen { let id_res = Identifier::from_string(id, Encoding::Base58); TokenDistributionRecipient::Identity(id_res.unwrap_or_default()) } else { - self.token_creator_error_message = Some( - "Invalid base58 identifier for perpetual distribution recipient" - .to_string(), + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Invalid base58 identifier for perpetual distribution recipient", + MessageType::Error, ); return Err( "Invalid base58 identifier for perpetual distribution recipient" @@ -2285,7 +2278,11 @@ impl TokensScreen { match self.parse_pre_programmed_distributions() { Ok(distributions) => distributions, Err(err) => { - self.token_creator_error_message = Some(err.clone()); + MessageBanner::set_global( + self.app_context.egui_ctx(), + &err, + MessageType::Error, + ); return Err(err.to_string()); } }; @@ -2484,7 +2481,6 @@ impl TokensScreen { self.minting_allow_choosing_destination_rules = ChangeControlRulesUI::default(); self.show_token_creator_confirmation_popup = false; - self.token_creator_error_message = None; // Reset document schemas self.document_schemas_input = String::new(); @@ -2495,18 +2491,27 @@ impl TokensScreen { fn add_token_to_tracked_tokens(&mut self, token_info: TokenInfo) -> Result { // Check if token is already added if self.all_known_tokens.contains_key(&token_info.token_id) { - self.backend_message = Some(( - "Token already in My Tokens".to_string(), + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Token already in My Tokens", MessageType::Error, - Utc::now(), - )); + ); return Ok(AppAction::None); } - // Set adding status with timestamp for elapsed time display + // Set adding status self.adding_token_start_time = Some(Utc::now()); self.adding_token_name = Some(token_info.token_name.clone()); - self.backend_message = Some(("Adding token...".to_string(), MessageType::Info, Utc::now())); + if let Some(h) = self.operation_banner.take() { + h.clear(); + } + let handle = MessageBanner::set_global( + self.app_context.egui_ctx(), + "Adding token...", + MessageType::Info, + ); + handle.with_elapsed(); + self.operation_banner = Some(handle); // Always save the token locally and refresh balances // The contract will be fetched automatically when needed @@ -2526,10 +2531,19 @@ impl TokensScreen { // If we have a next cursor: if let Some(next_cursor) = self.next_cursors.last().cloned() { // set status - let now = Utc::now().timestamp() as u64; - self.contract_search_status = ContractSearchStatus::WaitingForResult(now); + self.contract_search_status = ContractSearchStatus::WaitingForResult; + if let Some(h) = self.operation_banner.take() { + h.clear(); + } + let handle = MessageBanner::set_global( + self.app_context.egui_ctx(), + "Searching contracts...", + MessageType::Info, + ); + handle.with_elapsed(); + self.operation_banner = Some(handle); - // push the current one onto “previous” so we can go back + // push the current one onto "previous" so we can go back // if the user is on page N, and we have a nextCursor in next_cursors[N - 1] or so self.previous_cursors.push(next_cursor.clone()); @@ -2549,10 +2563,19 @@ impl TokensScreen { if self.search_current_page > 1 { // Move to (page - 1) self.search_current_page -= 1; - let now = Utc::now().timestamp() as u64; - self.contract_search_status = ContractSearchStatus::WaitingForResult(now); + self.contract_search_status = ContractSearchStatus::WaitingForResult; + if let Some(h) = self.operation_banner.take() { + h.clear(); + } + let handle = MessageBanner::set_global( + self.app_context.egui_ctx(), + "Searching contracts...", + MessageType::Info, + ); + handle.with_elapsed(); + self.operation_banner = Some(handle); - // The “last” previous_cursors item is the new page’s state + // The "last" previous_cursors item is the new page's state if let Some(prev_cursor) = self.previous_cursors.pop() { // Possibly pop from next_cursors if we want to re-insert it later // self.next_cursors.truncate(self.search_current_page - 1); @@ -2601,11 +2624,11 @@ impl TokensScreen { .app_context .remove_token_balance(token_to_remove.token_id, token_to_remove.identity_id) { - self.backend_message = Some(( + MessageBanner::set_global( + self.app_context.egui_ctx(), format!("Error removing token balance: {}", e), MessageType::Error, - Utc::now(), - )); + ); } else { self.refresh(); } @@ -2663,11 +2686,11 @@ impl TokensScreen { .db .remove_token(&token_to_remove, &self.app_context) { - self.backend_message = Some(( + MessageBanner::set_global( + self.app_context.egui_ctx(), format!("Error removing token balance: {}", e), MessageType::Error, - Utc::now(), - )); + ); } else { self.refresh(); } @@ -2801,8 +2824,6 @@ impl ScreenLike for TokensScreen { fn ui(&mut self, ctx: &Context) -> AppAction { let mut action = AppAction::None; - self.check_error_expiration(); - // Build top-right buttons let right_buttons = match self.tokens_subscreen { TokensSubscreen::MyTokens => vec![ @@ -2926,38 +2947,7 @@ impl ScreenLike for TokensScreen { } } - // Show either refreshing indicator or message, but not both - if let RefreshingStatus::Refreshing(start_time) = self.refreshing_status { - ui.add_space(25.0); // Space above - let now = Utc::now().timestamp() as u64; - let elapsed = now - start_time; - ui.horizontal(|ui| { - ui.add_space(10.0); - ui.label(format!("Refreshing... Time so far: {}", elapsed)); - ui.add(egui::widgets::Spinner::default().color(DashColors::DASH_BLUE)); - }); - ui.add_space(2.0); // Space below - } else if let Some((msg, msg_type, timestamp)) = self.backend_message.clone() { - ui.add_space(25.0); // Same space as refreshing indicator - let dark_mode = ui.ctx().style().visuals.dark_mode; - let color = match msg_type { - MessageType::Error => Color32::DARK_RED, - MessageType::Warning => DashColors::warning_color(dark_mode), - MessageType::Info => DashColors::text_primary(dark_mode), - MessageType::Success => Color32::DARK_GREEN, - }; - ui.horizontal(|ui| { - // Calculate remaining seconds - let now = Utc::now(); - let elapsed = now.signed_duration_since(timestamp); - let remaining = (10 - elapsed.num_seconds()).max(0); - - // Add the message with auto-dismiss countdown - let full_msg = format!("{} ({}s)", msg, remaining); - ui.label(egui::RichText::new(full_msg).color(color)); - }); - ui.add_space(2.0); // Same space below as refreshing indicator - } + // Elapsed display for refreshing is handled by the global MessageBanner if self.confirm_remove_identity_token_balance_popup { self.show_remove_identity_token_balance_popup(ui); @@ -2984,9 +2974,14 @@ impl ScreenLike for TokensScreen { AppAction::BackendTask(BackendTask::TokenTask(ref token_task)) if matches!(token_task.as_ref(), TokenTask::QueryMyTokenBalances) => { - self.refreshing_status = - RefreshingStatus::Refreshing(Utc::now().timestamp() as u64); - self.backend_message = None; // Clear any existing message + self.refreshing_status = RefreshingStatus::Refreshing; + if let Some(h) = self.operation_banner.take() { + h.clear(); + } + let handle = + MessageBanner::set_global(ctx, "Refreshing tokens...", MessageType::Info); + handle.with_elapsed(); + self.operation_banner = Some(handle); } AppAction::SetMainScreenThenGoToMainScreen(_) => { self.refreshing_status = RefreshingStatus::NotRefreshing; @@ -3037,20 +3032,24 @@ impl ScreenLike for TokensScreen { } fn display_message(&mut self, msg: &str, msg_type: MessageType) { - // Reset contract details loading on any error - if msg_type == MessageType::Error && self.contract_details_loading { + // Clear any active operation banner + self.operation_banner.take_and_clear(); + + // Banner display is handled globally by AppState; this is only for side-effects. + + // Reset contract details loading on any error/warning + if matches!(msg_type, MessageType::Error | MessageType::Warning) + && self.contract_details_loading + { self.contract_details_loading = false; } match self.tokens_subscreen { TokensSubscreen::TokenCreator => { - if msg.contains("Successfully registered token contract") { - self.token_creator_status = TokenCreatorStatus::Complete; - } else if msg.contains("Failed to register token contract") + if msg.contains("Failed to register token contract") | msg.contains("Error building contract V1") { - self.token_creator_status = TokenCreatorStatus::ErrorMessage(msg.to_string()); - self.token_creator_error_message = Some(msg.to_string()); + self.token_creator_status = TokenCreatorStatus::Error; } } TokensSubscreen::MyTokens => { @@ -3064,13 +3063,10 @@ impl ScreenLike for TokensScreen { self.adding_token_start_time = None; self.adding_token_name = None; } - self.backend_message = Some((msg.to_string(), msg_type, Utc::now())); self.refreshing_status = RefreshingStatus::NotRefreshing; - } else if msg.contains("Failed to query token pricing") { - self.backend_message = Some((msg.to_string(), MessageType::Error, Utc::now())); } else { tracing::debug!( - ?msg, + msg = msg, ?msg_type, "unsupported message received in token screen" ); @@ -3078,8 +3074,7 @@ impl ScreenLike for TokensScreen { } TokensSubscreen::SearchTokens => { if msg_type == MessageType::Error { - self.contract_search_status = - ContractSearchStatus::ErrorMessage(msg.to_string()); + self.contract_search_status = ContractSearchStatus::Error; // Clear adding status on error self.adding_token_start_time = None; self.adding_token_name = None; @@ -3087,20 +3082,20 @@ impl ScreenLike for TokensScreen { | msg.contains("Token already added") | msg.contains("Saved token to db") { - // Clear adding status and show success message + // Clear adding status (success message shown by global banner) self.adding_token_start_time = None; self.adding_token_name = None; - self.backend_message = Some(( - "Token added successfully!".to_string(), - MessageType::Success, - Utc::now(), - )); } } } } fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { + // Clear any active operation banner + if let Some(h) = self.operation_banner.take() { + h.clear(); + } + match backend_task_success_result { BackendTaskSuccessResult::DescriptionsByKeyword(descriptions, next_cursor) => { let mut sr = self.search_results.lock().unwrap(); diff --git a/src/ui/tokens/tokens_screen/my_tokens.rs b/src/ui/tokens/tokens_screen/my_tokens.rs index a97dfdc47..71fefeeb3 100644 --- a/src/ui/tokens/tokens_screen/my_tokens.rs +++ b/src/ui/tokens/tokens_screen/my_tokens.rs @@ -2,6 +2,7 @@ use crate::app::AppAction; use crate::backend_task::BackendTask; use crate::backend_task::tokens::TokenTask; use crate::model::amount::Amount; +use crate::ui::components::MessageBanner; use crate::ui::theme::DashColors; use crate::ui::tokens::burn_tokens_screen::BurnTokensScreen; use crate::ui::tokens::claim_tokens_screen::ClaimTokensScreen; @@ -21,8 +22,8 @@ use crate::ui::tokens::transfer_tokens_screen::TransferTokensScreen; use crate::ui::tokens::unfreeze_tokens_screen::UnfreezeTokensScreen; use crate::ui::tokens::update_token_config::UpdateTokenConfigScreen; use crate::ui::tokens::view_token_claims_screen::ViewTokenClaimsScreen; -use crate::ui::{Screen, ScreenType}; -use chrono::{Local, Utc}; +use crate::ui::{MessageType, Screen, ScreenType}; +use chrono::Local; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; use dash_sdk::dpp::data_contract::associated_token::token_configuration_convention::accessors::v0::TokenConfigurationConventionV0Getters; @@ -163,7 +164,9 @@ impl TokensScreen { // Otherwise, show the list of all tokens match self.render_token_list(ui) { Ok(list_action) => action |= list_action, - Err(e) => self.token_creator_error_message = Some(e), + Err(e) => { + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + } } } } @@ -274,11 +277,17 @@ impl TokensScreen { .min_size(egui::vec2(150.0, 36.0)); if ui.add(button).clicked() { - if let RefreshingStatus::Refreshing(_) = self.refreshing_status { + if let RefreshingStatus::Refreshing = self.refreshing_status { app_action = AppAction::None; } else { - self.refreshing_status = - RefreshingStatus::Refreshing(Utc::now().timestamp() as u64); + self.refreshing_status = RefreshingStatus::Refreshing; + let handle = MessageBanner::set_global( + ui.ctx(), + "Refreshing tokens...", + MessageType::Info, + ); + handle.with_elapsed(); + self.operation_banner = Some(handle); app_action = AppAction::Refresh; } } @@ -469,7 +478,14 @@ impl TokensScreen { identity_id: itb.identity_id, token_id: itb.token_id, }))); - self.refreshing_status = RefreshingStatus::Refreshing(Utc::now().timestamp() as u64); + self.refreshing_status = RefreshingStatus::Refreshing; + let handle = MessageBanner::set_global( + ui.ctx(), + "Estimating rewards...", + MessageType::Info, + ); + handle.with_elapsed(); + self.operation_banner = Some(handle); } }) }); @@ -482,7 +498,14 @@ impl TokensScreen { identity_id: itb.identity_id, token_id: itb.token_id, }))); - self.refreshing_status = RefreshingStatus::Refreshing(Utc::now().timestamp() as u64); + self.refreshing_status = RefreshingStatus::Refreshing; + let handle = MessageBanner::set_global( + self.app_context.egui_ctx(), + "Estimating rewards...", + MessageType::Info, + ); + handle.with_elapsed(); + self.operation_banner = Some(handle); } } @@ -647,12 +670,18 @@ impl TokensScreen { ui.close_kind(egui::UiKind::Menu); } Ok(None) => { - self.token_creator_error_message = - Some("Token contract not found".to_string()); + MessageBanner::set_global( + ui.ctx(), + "Token contract not found", + MessageType::Error, + ); } Err(e) => { - self.token_creator_error_message = - Some(format!("Error fetching token contract: {e}")); + MessageBanner::set_global( + ui.ctx(), + format!("Error fetching token contract: {e}"), + MessageType::Error, + ); } } } @@ -664,16 +693,11 @@ impl TokensScreen { match IdentityTokenInfo::try_from_identity_token_maybe_balance_with_actions_with_lookup(itb, &self.app_context) { Ok(info) => { action = AppAction::AddScreen( - Screen::MintTokensScreen( - MintTokensScreen::new( - info, - &self.app_context, - ), - ), + Screen::MintTokensScreen(MintTokensScreen::new(info, &self.app_context)), ); } Err(e) => { - self.token_creator_error_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } }; @@ -695,7 +719,7 @@ impl TokensScreen { ); } Err(e) => { - self.token_creator_error_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } }; ui.close_kind(egui::UiKind::Menu); @@ -707,16 +731,11 @@ impl TokensScreen { match IdentityTokenInfo::try_from_identity_token_maybe_balance_with_actions_with_lookup(itb, &self.app_context) { Ok(info) => { action = AppAction::AddScreen( - Screen::FreezeTokensScreen( - FreezeTokensScreen::new( - info, - &self.app_context, - ), - ), + Screen::FreezeTokensScreen(FreezeTokensScreen::new(info, &self.app_context)), ); } Err(e) => { - self.token_creator_error_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } }; ui.close_kind(egui::UiKind::Menu); @@ -728,16 +747,11 @@ impl TokensScreen { match IdentityTokenInfo::try_from_identity_token_maybe_balance_with_actions_with_lookup(itb, &self.app_context) { Ok(info) => { action = AppAction::AddScreen( - Screen::DestroyFrozenFundsScreen( - DestroyFrozenFundsScreen::new( - info, - &self.app_context, - ), - ), + Screen::DestroyFrozenFundsScreen(DestroyFrozenFundsScreen::new(info, &self.app_context)), ); } Err(e) => { - self.token_creator_error_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } }; ui.close_kind(egui::UiKind::Menu); @@ -749,16 +763,11 @@ impl TokensScreen { match IdentityTokenInfo::try_from_identity_token_maybe_balance_with_actions_with_lookup(itb, &self.app_context) { Ok(info) => { action = AppAction::AddScreen( - Screen::UnfreezeTokensScreen( - UnfreezeTokensScreen::new( - info, - &self.app_context, - ), - ), + Screen::UnfreezeTokensScreen(UnfreezeTokensScreen::new(info, &self.app_context)), ); } Err(e) => { - self.token_creator_error_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } }; ui.close_kind(egui::UiKind::Menu); @@ -780,7 +789,7 @@ impl TokensScreen { ); } Err(e) => { - self.token_creator_error_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } }; ui.close_kind(egui::UiKind::Menu); @@ -802,7 +811,7 @@ impl TokensScreen { ); } Err(e) => { - self.token_creator_error_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } }; ui.close_kind(egui::UiKind::Menu); @@ -833,7 +842,7 @@ impl TokensScreen { ); } Err(e) => { - self.token_creator_error_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } }; ui.close_kind(egui::UiKind::Menu); @@ -891,7 +900,7 @@ impl TokensScreen { ); } Err(e) => { - self.token_creator_error_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } }; ui.close_kind(egui::UiKind::Menu); @@ -930,7 +939,7 @@ impl TokensScreen { ); } Err(e) => { - self.token_creator_error_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } }; diff --git a/src/ui/tokens/tokens_screen/structs.rs b/src/ui/tokens/tokens_screen/structs.rs index 3aae61c78..a5be18eff 100644 --- a/src/ui/tokens/tokens_screen/structs.rs +++ b/src/ui/tokens/tokens_screen/structs.rs @@ -441,7 +441,7 @@ pub fn get_available_token_actions_for_identity( let identity_id = identity.identity.id(); let solo_action_taker = ActionTaker::SingleIdentity(identity_id); - let can_transfer = known_balance.is_some() && known_balance.unwrap() > 0; + let can_transfer = known_balance.is_some_and(|b| b > 0); let is_authorized = |takers: &AuthorizedActionTakers| { takers.allowed_for_action_taker( diff --git a/src/ui/tokens/tokens_screen/token_creator.rs b/src/ui/tokens/tokens_screen/token_creator.rs index b98924f7a..6e366a64c 100644 --- a/src/ui/tokens/tokens_screen/token_creator.rs +++ b/src/ui/tokens/tokens_screen/token_creator.rs @@ -1,5 +1,4 @@ use std::collections::{BTreeMap, HashSet}; -use chrono::Utc; use dash_sdk::dpp::data_contract::associated_token::token_configuration::v0::{TokenConfigurationPreset, TokenConfigurationPresetFeatures}; use dash_sdk::dpp::data_contract::associated_token::token_configuration::v0::TokenConfigurationPresetFeatures::{MostRestrictive, WithAllAdvancedActions, WithExtremeActions, WithMintingAndBurningActions, WithOnlyEmergencyAction}; use dash_sdk::dpp::data_contract::associated_token::token_distribution_rules::TokenDistributionRules; @@ -21,6 +20,8 @@ use crate::ui::components::styled::{StyledCheckbox}; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::Component; use crate::ui::components::identity_selector::IdentitySelector; +use crate::ui::components::MessageBanner; +use crate::ui::MessageType; use crate::ui::helpers::{add_identity_key_chooser, TransactionType}; use dash_sdk::dpp::identity::{Purpose, SecurityLevel}; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; @@ -283,12 +284,12 @@ impl TokensScreen { if crate::ui::helpers::info_icon_button(ui, "An optional description explaining what your token is for.\n\n\ This helps users understand the purpose of your token.\n\n\ - Maximum 100 characters.").clicked() { + 3–100 characters, or leave blank.").clicked() { self.show_pop_up_info = Some( "Description\n\n\ An optional description explaining what your token is for.\n\n\ This helps users understand the purpose of your token.\n\n\ - Maximum 100 characters.".to_string() + 3–100 characters, or leave blank.".to_string() ); } }); @@ -426,11 +427,10 @@ impl TokensScreen { match self.parse_token_build_args() { Ok(args) => { self.cached_build_args = Some(args); - self.token_creator_error_message = None; self.show_token_creator_confirmation_popup = true; } Err(err_msg) => { - self.token_creator_error_message = Some(err_msg); + MessageBanner::set_global(context, &err_msg, MessageType::Error); } } } @@ -556,7 +556,7 @@ impl TokensScreen { ui.end_row(); // Row 5: Token Description - ui.label("Token Description (max 100 chars):"); + ui.label("Token Description (3–100 chars):"); ui.text_edit_singleline(&mut self.token_description_input); ui.end_row(); }); @@ -869,11 +869,10 @@ impl TokensScreen { // If success, show the "confirmation popup" // Or skip the popup entirely and dispatch tasks right now self.cached_build_args = Some(args); - self.token_creator_error_message = None; self.show_token_creator_confirmation_popup = true; }, Err(err) => { - self.token_creator_error_message = Some(err); + MessageBanner::set_global(context, &err, MessageType::Error); } } } @@ -922,7 +921,7 @@ impl TokensScreen { ) { Ok(dc) => dc, Err(e) => { - self.token_creator_error_message = Some(format!("Error building contract V1: {e}")); + MessageBanner::set_global(context, format!("Error building contract V1: {e}"), MessageType::Error); return; } }; @@ -932,7 +931,7 @@ impl TokensScreen { self.json_popup_text = serde_json::to_string_pretty(&data_contract_json).expect("Expected to serialize json"); }, Err(err_msg) => { - self.token_creator_error_message = Some(err_msg); + MessageBanner::set_global(context, &err_msg, MessageType::Error); }, } } @@ -954,40 +953,7 @@ impl TokensScreen { self.render_data_contract_json_popup(ui); } - // 8) If we are waiting, show spinner / time elapsed - if let TokenCreatorStatus::WaitingForResult(start_time) = self.token_creator_status { - let now = Utc::now().timestamp() as u64; - let elapsed = now - start_time; - ui.add_space(10.0); - ui.horizontal(|ui| { - ui.label(format!( - "Registering token contract... elapsed {}s", - elapsed - )); - ui.add(egui::widgets::Spinner::default().color(DashColors::DASH_BLUE)); - }); - } - - // Show an error if we have one - if let Some(err_msg) = self.token_creator_error_message.clone() { - ui.add_space(10.0); - let error_color = DashColors::ERROR; - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label(RichText::new(format!("Error: {}", err_msg)).color(error_color)); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.token_creator_error_message = None; - } - }); - }); - ui.add_space(10.0); - } + // Elapsed display for token creation is handled by the global MessageBanner }); // Close the ScrollArea from line 40 @@ -1003,12 +969,11 @@ impl TokensScreen { fn update_selected_wallet(&mut self) { if let (Some(qid), Some(key)) = (&self.selected_identity, &self.selected_key) { - self.selected_wallet = crate::ui::identities::get_selected_wallet( - qid, - None, - Some(key), - &mut self.token_creator_error_message, - ); + self.selected_wallet = crate::ui::identities::get_selected_wallet(qid, None, Some(key)) + .unwrap_or_else(|e| { + MessageBanner::set_global(self.app_context.egui_ctx(), &e, MessageType::Error); + None + }); } } @@ -1019,7 +984,7 @@ impl TokensScreen { }; if let Err(e) = try_open_wallet_no_password(wallet) { - self.token_creator_error_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } if wallet_needs_unlock(wallet) { @@ -1115,6 +1080,13 @@ impl TokensScreen { let token_names = self.parse_token_names(&mut contract_keywords)?; let token_description = if !self.token_description_input.is_empty() { + let len = self.token_description_input.chars().count(); + if !(3..=100).contains(&len) { + return Err( + "Token description must be either empty or between 3 and 100 characters long" + .to_string(), + ); + } Some(self.token_description_input.clone()) } else { None @@ -1476,7 +1448,7 @@ impl TokensScreen { match self.parse_token_build_args() { Ok(a) => a, Err(err) => { - self.token_creator_error_message = Some(err); + MessageBanner::set_global(ui.ctx(), &err, MessageType::Error); self.close_token_creator_confirmation_popup(); return AppAction::None; } @@ -1489,8 +1461,11 @@ impl TokensScreen { match (&self.selected_identity, &self.selected_key) { (Some(id), Some(key)) => (id.clone(), key.clone()), _ => { - self.token_creator_error_message = - Some("Please select an identity and signing key.".to_string()); + MessageBanner::set_global( + ui.ctx(), + "Please select an identity and signing key.", + MessageType::Error, + ); self.close_token_creator_confirmation_popup(); return AppAction::None; } @@ -1535,8 +1510,14 @@ impl TokensScreen { ]; action = AppAction::BackendTasks(tasks, BackendTasksExecutionMode::Sequential); - let now = Utc::now().timestamp() as u64; - self.token_creator_status = TokenCreatorStatus::WaitingForResult(now); + self.token_creator_status = TokenCreatorStatus::WaitingForResult; + let handle = MessageBanner::set_global( + self.app_context.egui_ctx(), + "Creating token...", + MessageType::Info, + ); + handle.with_elapsed(); + self.operation_banner = Some(handle); self.close_token_creator_confirmation_popup(); } ConfirmationStatus::Canceled => { diff --git a/src/ui/tokens/transfer_tokens_screen.rs b/src/ui/tokens/transfer_tokens_screen.rs index 338d2faac..5005ebee2 100644 --- a/src/ui/tokens/transfer_tokens_screen.rs +++ b/src/ui/tokens/transfer_tokens_screen.rs @@ -17,6 +17,7 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; +use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt, ResultBannerExt}; use crate::ui::helpers::{TransactionType, add_key_chooser}; use crate::ui::identities::keys::add_key_screen::AddKeyScreen; use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; @@ -24,37 +25,37 @@ use crate::ui::theme::DashColors; use crate::ui::{MessageType, Screen, ScreenLike}; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; -use dash_sdk::dpp::prelude::TimestampMillis; + use dash_sdk::platform::{Identifier, IdentityPublicKey}; use eframe::egui::{self, Context, Ui}; use eframe::egui::{Frame, Margin}; use egui::{Color32, RichText}; use std::collections::HashSet; use std::sync::{Arc, RwLock}; -use std::time::{SystemTime, UNIX_EPOCH}; use crate::ui::identities::get_selected_wallet; +use crate::ui::tokens::validate_signing_key; use super::tokens_screen::IdentityTokenBalance; #[derive(PartialEq)] pub enum TransferTokensStatus { NotStarted, - WaitingForResult(TimestampMillis), - ErrorMessage(String), + WaitingForResult, + Error, Complete, } pub struct TransferTokensScreen { - pub identity: QualifiedIdentity, + identity: Option, pub identity_token_balance: IdentityTokenBalance, known_identities: Vec, selected_key: Option, show_advanced_options: bool, - pub public_note: Option, - pub receiver_identity_id: String, - pub amount: Option, - pub amount_input: Option, + public_note: Option, + receiver_identity_id: String, + amount: Option, + amount_input: Option, transfer_tokens_status: TransferTokensStatus, max_amount: Amount, pub app_context: Arc, @@ -63,6 +64,8 @@ pub struct TransferTokensScreen { wallet_unlock_popup: WalletUnlockPopup, // Fee result from completed operation completed_fee_result: Option, + // Banner handle for elapsed time display + refresh_banner: Option, } impl TransferTokensScreen { @@ -72,24 +75,47 @@ impl TransferTokensScreen { ) -> Self { let known_identities = app_context .load_local_qualified_identities() - .expect("Identities not loaded"); + .or_show_error(app_context.egui_ctx()) + .unwrap_or_default(); let identity = known_identities .iter() .find(|identity| identity.identity.id() == identity_token_balance.identity_id) - .expect("Identity not found") - .clone(); + .cloned() + .or_else(|| { + MessageBanner::set_global( + app_context.egui_ctx(), + "Identity not found in local store", + MessageType::Error, + ); + // Fallback to first available identity for degraded state. + known_identities.first().cloned() + }); + + if identity.is_none() { + MessageBanner::set_global( + app_context.egui_ctx(), + "No identities loaded — cannot open Transfer screen", + MessageType::Error, + ); + } + let max_amount = Amount::from(&identity_token_balance); - let identity_clone = identity.identity.clone(); - let selected_key = identity_clone.get_first_public_key_matching( - Purpose::AUTHENTICATION, - HashSet::from([SecurityLevel::CRITICAL]), - KeyType::all_key_types().into(), - false, - ); - let mut error_message = None; - let selected_wallet = - get_selected_wallet(&identity, None, selected_key, &mut error_message); + let selected_key: Option = identity.as_ref().and_then(|id| { + id.identity + .get_first_public_key_matching( + Purpose::AUTHENTICATION, + HashSet::from([SecurityLevel::CRITICAL]), + KeyType::all_key_types().into(), + false, + ) + .cloned() + }); + let selected_wallet = identity.as_ref().and_then(|id| { + get_selected_wallet(id, None, selected_key.as_ref()) + .or_show_error(app_context.egui_ctx()) + .unwrap_or(None) + }); let amount = Some(Amount::from(&identity_token_balance).with_value(0)); @@ -97,7 +123,7 @@ impl TransferTokensScreen { identity, identity_token_balance, known_identities, - selected_key: selected_key.cloned(), + selected_key, show_advanced_options: false, public_note: None, receiver_identity_id: String::new(), @@ -110,6 +136,7 @@ impl TransferTokensScreen { selected_wallet, wallet_unlock_popup: WalletUnlockPopup::new(), completed_fee_result: None, + refresh_banner: None, } } @@ -139,8 +166,8 @@ impl TransferTokensScreen { // Check if input should be disabled when operation is in progress let enabled = match self.transfer_tokens_status { - TransferTokensStatus::WaitingForResult(_) | TransferTokensStatus::Complete => false, - TransferTokensStatus::NotStarted | TransferTokensStatus::ErrorMessage(_) => { + TransferTokensStatus::WaitingForResult | TransferTokensStatus::Complete => false, + TransferTokensStatus::NotStarted | TransferTokensStatus::Error => { amount_input.set_max_amount(Some(self.max_amount.value())); true } @@ -153,6 +180,12 @@ impl TransferTokensScreen { } fn render_to_identity_input(&mut self, ui: &mut Ui) { + let exclude: Vec<_> = self + .identity + .as_ref() + .map(|id| id.identity.id()) + .into_iter() + .collect(); let _response = ui.add( IdentitySelector::new( "transfer_recipient_selector", @@ -161,7 +194,7 @@ impl TransferTokensScreen { ) .width(300.0) .label("Recipient:") - .exclude(&[self.identity.identity.id()]), + .exclude(&exclude), ); } @@ -193,52 +226,74 @@ impl TransferTokensScreen { } fn confirmation_ok(&mut self) -> AppAction { - let signing_key = match self.selected_key.clone() { - Some(key) => key, - None => { - self.transfer_tokens_status = - TransferTokensStatus::ErrorMessage("No signing key selected".into()); - return AppAction::None; - } - }; - if self.amount.is_none() || self.amount == Some(Amount::new(0, 0)) { - self.transfer_tokens_status = - TransferTokensStatus::ErrorMessage("Invalid amount".into()); + self.transfer_tokens_status = TransferTokensStatus::Error; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Invalid amount", + MessageType::Error, + ); return AppAction::None; } - let receiver_id = match Identifier::from_string_try_encodings( + let Ok(receiver_id) = Identifier::from_string_try_encodings( &self.receiver_identity_id, &[ dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58, dash_sdk::dpp::platform_value::string_encoding::Encoding::Hex, ], - ) { - Ok(id) => id, - Err(_) => { - self.transfer_tokens_status = - TransferTokensStatus::ErrorMessage("Invalid receiver".into()); + ) else { + self.transfer_tokens_status = TransferTokensStatus::Error; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Invalid receiver", + MessageType::Error, + ); + return AppAction::None; + }; + + // Validate signing key before transitioning to waiting state + let Some(signing_key) = validate_signing_key(&self.app_context, self.selected_key.as_ref()) + else { + return AppAction::None; + }; + + let data_contract = match self + .app_context + .get_unqualified_contract_by_id(&self.identity_token_balance.data_contract_id) + { + Ok(Some(contract)) => Arc::new(contract), + _ => { + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Data contract not found", + MessageType::Error, + ); return AppAction::None; } }; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - self.transfer_tokens_status = TransferTokensStatus::WaitingForResult(now); - - let data_contract = Arc::new( - self.app_context - .get_unqualified_contract_by_id(&self.identity_token_balance.data_contract_id) - .expect("Failed to get data contract") - .expect("Data contract not found"), + let Some(identity) = self.identity.clone() else { + MessageBanner::set_global( + self.app_context.egui_ctx(), + "No identity loaded — cannot transfer tokens", + MessageType::Error, + ); + return AppAction::None; + }; + + self.transfer_tokens_status = TransferTokensStatus::WaitingForResult; + let handle = MessageBanner::set_global( + self.app_context.egui_ctx(), + "Transferring tokens...", + MessageType::Info, ); + handle.with_elapsed(); + self.refresh_banner = Some(handle); AppAction::BackendTask(BackendTask::TokenTask(Box::new( TokenTask::TransferTokens { - sending_identity: self.identity.clone(), + sending_identity: identity, recipient_id: receiver_id, amount: self.amount.clone().unwrap_or(Amount::new(0, 0)).value(), data_contract, @@ -259,15 +314,18 @@ impl TransferTokensScreen { } impl ScreenLike for TransferTokensScreen { - fn display_message(&mut self, message: &str, message_type: MessageType) { - if let MessageType::Error = message_type { - self.transfer_tokens_status = TransferTokensStatus::ErrorMessage(message.to_string()); + fn display_message(&mut self, _message: &str, message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. + if matches!(message_type, MessageType::Error | MessageType::Warning) { + self.refresh_banner.take_and_clear(); + self.transfer_tokens_status = TransferTokensStatus::Error; } } fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { if let BackendTaskSuccessResult::TransferredTokens(fee_result) = backend_task_success_result { + self.refresh_banner.take_and_clear(); self.completed_fee_result = Some(fee_result); self.transfer_tokens_status = TransferTokensStatus::Complete; } @@ -275,23 +333,47 @@ impl ScreenLike for TransferTokensScreen { fn refresh(&mut self) { // Refresh the identity because there might be new keys - self.identity = self - .app_context - .load_local_qualified_identities() - .unwrap() - .into_iter() - .find(|identity| identity.identity.id() == self.identity.identity.id()) - .unwrap(); - let token_balances = self - .app_context - .db - .get_identity_token_balances(&self.app_context) - .expect("Token balances not loaded"); - self.max_amount = token_balances - .values() - .find(|balance| balance.identity_id == self.identity.identity.id()) - .map(Amount::from) - .unwrap_or_default(); + if let Some(current_id) = self.identity.as_ref().map(|id| id.identity.id()) { + let all_ids = self + .app_context + .load_local_qualified_identities() + .unwrap_or_else(|e| { + MessageBanner::set_global( + self.app_context.egui_ctx(), + format!("Failed to load local identities: {e}"), + MessageType::Error, + ); + vec![] + }); + if let Some(refreshed) = all_ids + .into_iter() + .find(|identity| identity.identity.id() == current_id) + { + self.identity = Some(refreshed); + } + } + if let Some(current_id) = self.identity.as_ref().map(|id| id.identity.id()) { + match self + .app_context + .db + .get_identity_token_balances(&self.app_context) + { + Ok(token_balances) => { + self.max_amount = token_balances + .values() + .find(|balance| balance.identity_id == current_id) + .map(Amount::from) + .unwrap_or_default(); + } + Err(e) => { + MessageBanner::set_global( + self.app_context.egui_ctx(), + format!("Failed to load token balances: {e}"), + MessageType::Error, + ); + } + } + } } /// Renders the UI components for the withdrawal screen @@ -328,6 +410,16 @@ impl ScreenLike for TransferTokensScreen { return self.show_success(ui); } + // Guard: require a loaded identity before rendering interactive content. + // Use as_ref() to avoid a per-frame clone of the full QualifiedIdentity. + let Some(identity) = self.identity.as_ref() else { + ui.colored_label( + DashColors::error_color(dark_mode), + "No identity loaded. Please load an identity and reopen this screen.", + ); + return AppAction::None; + }; + ui.heading(format!( "Transfer {}", self.identity_token_balance.token_alias @@ -335,36 +427,41 @@ impl ScreenLike for TransferTokensScreen { ui.add_space(10.0); let has_keys = if self.app_context.is_developer_mode() { - !self.identity.identity.public_keys().is_empty() + !identity.identity.public_keys().is_empty() } else { - !self - .identity + !identity .available_authentication_keys_with_critical_security_level() .is_empty() }; if !has_keys { + let identity_type = identity.identity_type; + // Extract key data before releasing the borrow (needed for click handlers). + let key = identity + .identity + .get_first_public_key_matching( + Purpose::AUTHENTICATION, + HashSet::from([SecurityLevel::CRITICAL]), + KeyType::all_key_types().into(), + false, + ) + .cloned(); ui.colored_label( DashColors::error_color(dark_mode), format!( "You do not have any authentication keys with CRITICAL security level loaded for this {} identity.", - self.identity.identity_type + identity_type ), ); ui.add_space(10.0); - let key = self.identity.identity.get_first_public_key_matching( - Purpose::AUTHENTICATION, - HashSet::from([SecurityLevel::CRITICAL]), - KeyType::all_key_types().into(), - false, - ); - if let Some(key) = key { if ui.button("Check Keys").clicked() { + // Clone only on button click, not every frame. + let identity = self.identity.clone().expect("checked above"); return AppAction::AddScreen(Screen::KeyInfoScreen(KeyInfoScreen::new( - self.identity.clone(), - key.clone(), + identity, + key, None, &self.app_context, ))); @@ -373,15 +470,17 @@ impl ScreenLike for TransferTokensScreen { } if ui.button("Add key").clicked() { + // Clone only on button click, not every frame. + let identity = self.identity.clone().expect("checked above"); return AppAction::AddScreen(Screen::AddKeyScreen(AddKeyScreen::new( - self.identity.clone(), + identity, &self.app_context, ))); } } else { if let Some(wallet) = &self.selected_wallet { if let Err(e) = try_open_wallet_no_password(wallet) { - self.transfer_tokens_status = TransferTokensStatus::ErrorMessage(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } if wallet_needs_unlock(wallet) { ui.add_space(10.0); @@ -410,10 +509,12 @@ impl ScreenLike for TransferTokensScreen { if self.show_advanced_options { ui.heading("1. Select the key to sign the transaction with"); ui.add_space(10.0); + // Reborrow identity as ref for add_key_chooser; selected_key is &mut self. + let identity = self.identity.as_ref().expect("checked above"); add_key_chooser( ui, &self.app_context, - &self.identity, + identity, &mut self.selected_key, TransactionType::TokenTransfer, ); @@ -490,7 +591,8 @@ impl ScreenLike for TransferTokensScreen { // Transfer button - let has_enough_balance = self.identity.identity.balance() > estimated_fee; + let identity = self.identity.as_ref().expect("checked above"); + let has_enough_balance = identity.identity.balance() > estimated_fee; let ready = self.amount.is_some() && !self.receiver_identity_id.is_empty() && self.selected_key.is_some() @@ -518,12 +620,18 @@ impl ScreenLike for TransferTokensScreen { { // Use the amount value directly since it's already parsed if self.amount.as_ref().is_some_and(|v| v > &self.max_amount) { - self.transfer_tokens_status = TransferTokensStatus::ErrorMessage( - "Amount exceeds available balance".to_string(), + self.transfer_tokens_status = TransferTokensStatus::Error; + MessageBanner::set_global( + ui.ctx(), + "Amount exceeds available balance", + MessageType::Error, ); } else if self.amount.as_ref().is_none_or(|a| a.value() == 0) { - self.transfer_tokens_status = TransferTokensStatus::ErrorMessage( - "Amount must be greater than zero".to_string(), + self.transfer_tokens_status = TransferTokensStatus::Error; + MessageBanner::set_global( + ui.ctx(), + "Amount must be greater than zero", + MessageType::Error, ); } else { let msg = format!( @@ -549,41 +657,11 @@ impl ScreenLike for TransferTokensScreen { TransferTokensStatus::NotStarted => { // Do nothing } - TransferTokensStatus::WaitingForResult(start_time) => { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let elapsed_seconds = now - start_time; - - let display_time = if elapsed_seconds < 60 { - format!( - "{} second{}", - elapsed_seconds, - if elapsed_seconds == 1 { "" } else { "s" } - ) - } else { - let minutes = elapsed_seconds / 60; - let seconds = elapsed_seconds % 60; - format!( - "{} minute{} and {} second{}", - minutes, - if minutes == 1 { "" } else { "s" }, - seconds, - if seconds == 1 { "" } else { "s" } - ) - }; - - ui.label(format!( - "Transferring... Time taken so far: {}", - display_time - )); + TransferTokensStatus::WaitingForResult => { + // Elapsed display is handled by the global MessageBanner } - TransferTokensStatus::ErrorMessage(msg) => { - ui.colored_label( - DashColors::error_color(dark_mode), - format!("Error: {}", msg), - ); + TransferTokensStatus::Error => { + // Error display is handled by the global MessageBanner } TransferTokensStatus::Complete => { // Handled above diff --git a/src/ui/tokens/unfreeze_tokens_screen.rs b/src/ui/tokens/unfreeze_tokens_screen.rs index 90aab2d03..1778c0e04 100644 --- a/src/ui/tokens/unfreeze_tokens_screen.rs +++ b/src/ui/tokens/unfreeze_tokens_screen.rs @@ -16,11 +16,13 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; +use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt}; use crate::ui::helpers::{TransactionType, add_key_chooser, render_group_action_text}; use crate::ui::identities::get_selected_wallet; use crate::ui::identities::keys::add_key_screen::AddKeyScreen; use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; use crate::ui::theme::DashColors; +use crate::ui::tokens::validate_signing_key; use crate::ui::{MessageType, Screen, ScreenLike}; use dash_sdk::dpp::data_contract::GroupContractPosition; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -38,20 +40,19 @@ use eframe::egui::{self, Color32, Context, Frame, Margin, Ui}; use egui::RichText; use std::collections::HashSet; use std::sync::{Arc, RwLock}; -use std::time::{SystemTime, UNIX_EPOCH}; /// The states for the unfreeze flow #[derive(PartialEq)] pub enum UnfreezeTokensStatus { NotStarted, - WaitingForResult(u64), - ErrorMessage(String), + WaitingForResult, + Error, Complete, } /// A screen that allows unfreezing a previously frozen identity's tokens for a specific contract pub struct UnfreezeTokensScreen { - pub identity: QualifiedIdentity, + identity: QualifiedIdentity, pub identity_token_info: IdentityTokenInfo, selected_key: Option, show_advanced_options: bool, @@ -69,7 +70,6 @@ pub struct UnfreezeTokensScreen { pub unfreeze_identity_id: String, status: UnfreezeTokensStatus, - error_message: Option, // Basic references pub app_context: Arc, @@ -82,14 +82,14 @@ pub struct UnfreezeTokensScreen { wallet_unlock_popup: WalletUnlockPopup, // Fee result from completed operation completed_fee_result: Option, + // Banner handle for elapsed time display + refresh_banner: Option, } impl UnfreezeTokensScreen { pub fn new(identity_token_info: IdentityTokenInfo, app_context: &Arc) -> Self { // TODO: filter to include only frozen identities - let frozen_identities = app_context - .load_local_qualified_identities() - .expect("Identities not loaded"); + let frozen_identities = super::load_identities_with_banner(app_context); let possible_key = identity_token_info .identity @@ -102,7 +102,7 @@ impl UnfreezeTokensScreen { ) .cloned(); - let mut error_message = None; + let set_error_banner = |msg: &str| super::set_error_banner(app_context, msg); let group = match identity_token_info .token_config @@ -110,32 +110,30 @@ impl UnfreezeTokensScreen { .authorized_to_make_change_action_takers() { AuthorizedActionTakers::NoOne => { - error_message = Some("Burning is not allowed on this token".to_string()); + set_error_banner("Unfreezing is not allowed on this token"); None } AuthorizedActionTakers::ContractOwner => { if identity_token_info.data_contract.contract.owner_id() != identity_token_info.identity.identity.id() { - error_message = Some( - "You are not allowed to burn this token. Only the contract owner is." - .to_string(), + set_error_banner( + "You are not allowed to unfreeze this token. Only the contract owner is.", ); } None } AuthorizedActionTakers::Identity(identifier) => { if identifier != &identity_token_info.identity.identity.id() { - error_message = Some("You are not allowed to burn this token".to_string()); + set_error_banner("You are not allowed to unfreeze this token"); } None } AuthorizedActionTakers::MainGroup => { match identity_token_info.token_config.main_control_group() { None => { - error_message = Some( - "Invalid contract: No main control group, though one should exist" - .to_string(), + set_error_banner( + "Invalid contract: No main control group, though one should exist", ); None } @@ -147,7 +145,7 @@ impl UnfreezeTokensScreen { { Ok(group) => Some((group_pos, group.clone())), Err(e) => { - error_message = Some(format!("Invalid contract: {}", e)); + set_error_banner(&format!("Invalid contract: {}", e)); None } } @@ -162,7 +160,7 @@ impl UnfreezeTokensScreen { { Ok(group) => Some((*group_pos, group.clone())), Err(e) => { - error_message = Some(format!("Invalid contract: {}", e)); + set_error_banner(&format!("Invalid contract: {}", e)); None } } @@ -185,12 +183,12 @@ impl UnfreezeTokensScreen { }; // Attempt to get an unlocked wallet reference - let selected_wallet = get_selected_wallet( - &identity_token_info.identity, - None, - possible_key.as_ref(), - &mut error_message, - ); + let selected_wallet = + get_selected_wallet(&identity_token_info.identity, None, possible_key.as_ref()) + .unwrap_or_else(|e| { + set_error_banner(&e); + None + }); Self { identity: identity_token_info.identity.clone(), @@ -203,13 +201,13 @@ impl UnfreezeTokensScreen { public_note: None, unfreeze_identity_id: String::new(), status: UnfreezeTokensStatus::NotStarted, - error_message, app_context: app_context.clone(), confirmation_dialog: None, selected_wallet, wallet_unlock_popup: WalletUnlockPopup::new(), frozen_identities, completed_fee_result: None, + refresh_banner: None, } } @@ -252,36 +250,37 @@ impl UnfreezeTokensScreen { } fn confirmation_ok(&mut self) -> AppAction { - let signing_key = match self.selected_key.clone() { - Some(key) => key, - None => { - self.error_message = Some("No signing key selected".into()); - self.status = UnfreezeTokensStatus::ErrorMessage("No key selected".into()); - return AppAction::None; - } - }; - // Validate user input - let unfreeze_id = match Identifier::from_string_try_encodings( + let Ok(unfreeze_id) = Identifier::from_string_try_encodings( &self.unfreeze_identity_id, &[ dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58, dash_sdk::dpp::platform_value::string_encoding::Encoding::Hex, ], - ) { - Ok(id) => id, - Err(_) => { - self.error_message = Some("Please enter a valid identity ID.".into()); - self.status = UnfreezeTokensStatus::ErrorMessage("Invalid identity ID".into()); - return AppAction::None; - } + ) else { + self.status = UnfreezeTokensStatus::Error; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Please enter a valid identity ID.", + MessageType::Error, + ); + return AppAction::None; }; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - self.status = UnfreezeTokensStatus::WaitingForResult(now); + // Validate signing key before transitioning to waiting state + let Some(signing_key) = validate_signing_key(&self.app_context, self.selected_key.as_ref()) + else { + return AppAction::None; + }; + + self.status = UnfreezeTokensStatus::WaitingForResult; + let handle = MessageBanner::set_global( + self.app_context.egui_ctx(), + "Unfreezing tokens...", + MessageType::Info, + ); + handle.with_elapsed(); + self.refresh_banner = Some(handle); // Grab the data contract for this token from the app context let data_contract = Arc::new(self.identity_token_info.data_contract.contract.clone()); @@ -334,15 +333,17 @@ impl UnfreezeTokensScreen { } impl ScreenLike for UnfreezeTokensScreen { - fn display_message(&mut self, message: &str, message_type: MessageType) { - if let MessageType::Error = message_type { - self.status = UnfreezeTokensStatus::ErrorMessage(message.to_string()); - self.error_message = Some(message.to_string()); + fn display_message(&mut self, _message: &str, message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. + if matches!(message_type, MessageType::Error | MessageType::Warning) { + self.refresh_banner.take_and_clear(); + self.status = UnfreezeTokensStatus::Error; } } fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { if let BackendTaskSuccessResult::UnfrozeTokens(fee_result) = backend_task_success_result { + self.refresh_banner.take_and_clear(); self.completed_fee_result = Some(fee_result); self.status = UnfreezeTokensStatus::Complete; } @@ -455,7 +456,7 @@ impl ScreenLike for UnfreezeTokensScreen { // Possibly handle locked wallet scenario if let Some(wallet) = &self.selected_wallet { if let Err(e) = try_open_wallet_no_password(wallet) { - self.error_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } if wallet_needs_unlock(wallet) { ui.add_space(10.0); @@ -609,33 +610,11 @@ impl ScreenLike for UnfreezeTokensScreen { UnfreezeTokensStatus::NotStarted => { // no-op } - UnfreezeTokensStatus::WaitingForResult(start_time) => { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let elapsed = now - start_time; - ui.label(format!("Unfreezing... elapsed: {}s", elapsed)); + UnfreezeTokensStatus::WaitingForResult => { + // Elapsed display is handled by the global MessageBanner } - UnfreezeTokensStatus::ErrorMessage(msg) => { - let error_color = DashColors::ERROR; - let msg = msg.clone(); - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label( - RichText::new(format!("Error: {}", msg)).color(error_color), - ); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.status = UnfreezeTokensStatus::NotStarted; - } - }); - }); + UnfreezeTokensStatus::Error => { + // Error display is handled by the global MessageBanner } UnfreezeTokensStatus::Complete => { // handled above diff --git a/src/ui/tokens/update_token_config.rs b/src/ui/tokens/update_token_config.rs index 1de17195f..f17cb6c5f 100644 --- a/src/ui/tokens/update_token_config.rs +++ b/src/ui/tokens/update_token_config.rs @@ -13,13 +13,14 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; +use crate::ui::components::{MessageBanner, OptionBannerExt, ResultBannerExt}; use crate::ui::helpers::{TransactionType, add_key_chooser, render_group_action_text}; use crate::ui::identities::get_selected_wallet; use crate::ui::identities::keys::add_key_screen::AddKeyScreen; use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; use crate::ui::theme::DashColors; +use crate::ui::tokens::validate_signing_key; use crate::ui::{MessageType, Screen, ScreenLike}; -use chrono::{DateTime, Utc}; use dash_sdk::dpp::data_contract::GroupContractPosition; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::data_contract::accessors::v1::DataContractV1Getters; @@ -36,24 +37,24 @@ use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::platform::{DataContract, Identifier, IdentityPublicKey}; use eframe::egui::{self, Color32, Context, Ui}; -use egui::{Frame, Margin, RichText}; +use egui::RichText; use std::collections::HashSet; use std::sync::{Arc, RwLock}; #[derive(Debug, Clone, PartialEq)] pub enum UpdateTokenConfigStatus { NotUpdating, - Updating(DateTime), + Updating, + Complete, } pub struct UpdateTokenConfigScreen { pub identity_token_info: IdentityTokenInfo, - backend_message: Option<(String, MessageType, DateTime)>, update_status: UpdateTokenConfigStatus, pub app_context: Arc, pub change_item: TokenConfigurationChangeItem, - pub update_text: String, - pub text_input_error: String, + update_text: String, + text_input_error: String, signing_key: Option, show_advanced_options: bool, identity: QualifiedIdentity, @@ -63,14 +64,15 @@ pub struct UpdateTokenConfigScreen { pub group_action_id: Option, // Input state fields - pub authorized_identity_input: Option, - pub authorized_group_input: Option, + authorized_identity_input: Option, + authorized_group_input: Option, selected_wallet: Option>>, wallet_unlock_popup: WalletUnlockPopup, - error_message: Option, // unused // Fee result from completed operation completed_fee_result: Option, + // Banner handle for elapsed time display + refresh_banner: Option, } impl UpdateTokenConfigScreen { @@ -86,8 +88,6 @@ impl UpdateTokenConfigScreen { ) .cloned(); - let mut error_message = None; - // Initialize with no group - will be set when user selects a change item let group = None; @@ -95,16 +95,13 @@ impl UpdateTokenConfigScreen { let is_unilateral_group_member = false; // Attempt to get an unlocked wallet reference - let selected_wallet = get_selected_wallet( - &identity_token_info.identity, - None, - possible_key.as_ref(), - &mut error_message, - ); + let selected_wallet = + get_selected_wallet(&identity_token_info.identity, None, possible_key.as_ref()) + .or_show_error(app_context.egui_ctx()) + .unwrap_or(None); Self { identity_token_info: identity_token_info.clone(), - backend_message: None, update_status: UpdateTokenConfigStatus::NotUpdating, app_context: app_context.clone(), change_item: TokenConfigurationChangeItem::TokenConfigurationNoChange, @@ -119,13 +116,13 @@ impl UpdateTokenConfigScreen { selected_wallet, wallet_unlock_popup: WalletUnlockPopup::new(), - error_message, identity: identity_token_info.identity, group, is_unilateral_group_member, group_action_id: None, completed_fee_result: None, + refresh_banner: None, } } @@ -135,35 +132,40 @@ impl UpdateTokenConfigScreen { .token_config .authorized_action_takers_for_configuration_item(&self.change_item); - let mut error_message = None; let group = match authorized_action_takers { AuthorizedActionTakers::NoOne => { - error_message = Some("This action is not allowed on this token".to_string()); + super::set_error_banner( + &self.app_context, + "This action is not allowed on this token", + ); None } AuthorizedActionTakers::ContractOwner => { if self.identity_token_info.data_contract.contract.owner_id() != self.identity_token_info.identity.identity.id() { - error_message = Some( - "You are not allowed to perform this action. Only the contract owner is." - .to_string(), + super::set_error_banner( + &self.app_context, + "You are not allowed to perform this action. Only the contract owner is.", ); } None } AuthorizedActionTakers::Identity(identifier) => { if identifier != self.identity_token_info.identity.identity.id() { - error_message = Some("You are not allowed to perform this action".to_string()); + super::set_error_banner( + &self.app_context, + "You are not allowed to perform this action", + ); } None } AuthorizedActionTakers::MainGroup => { match self.identity_token_info.token_config.main_control_group() { None => { - error_message = Some( - "Invalid contract: No main control group, though one should exist" - .to_string(), + super::set_error_banner( + &self.app_context, + "Invalid contract: No main control group, though one should exist", ); None } @@ -176,7 +178,10 @@ impl UpdateTokenConfigScreen { { Ok(group) => Some((group_pos, group.clone())), Err(e) => { - error_message = Some(format!("Invalid contract: {}", e)); + super::set_error_banner( + &self.app_context, + &format!("Invalid contract: {}", e), + ); None } } @@ -192,7 +197,10 @@ impl UpdateTokenConfigScreen { { Ok(group) => Some((group_pos, group.clone())), Err(e) => { - error_message = Some(format!("Invalid contract: {}", e)); + super::set_error_banner( + &self.app_context, + &format!("Invalid contract: {}", e), + ); None } } @@ -200,11 +208,6 @@ impl UpdateTokenConfigScreen { }; self.group = group; - if let Some(error) = error_message { - self.error_message = Some(error); - } else { - self.error_message = None; - } // Update is_unilateral_group_member based on new group self.is_unilateral_group_member = false; @@ -751,12 +754,12 @@ impl UpdateTokenConfigScreen { { ui.add_space(20.0); if ui.add(button).clicked() { - let group_info = if self.group_action_id.is_some() { + let group_info = if let Some(action_id) = self.group_action_id { self.group.as_ref().map(|(pos, _)| { GroupStateTransitionInfoStatus::GroupStateTransitionInfoOtherSigner( GroupStateTransitionInfo { group_contract_position: *pos, - action_id: self.group_action_id.unwrap(), + action_id, action_is_proposer: false, }, ) @@ -767,20 +770,31 @@ impl UpdateTokenConfigScreen { }) }; - self.update_status = UpdateTokenConfigStatus::Updating(Utc::now()); - action |= AppAction::BackendTask(BackendTask::TokenTask(Box::new( - TokenTask::UpdateTokenConfig { - identity_token_info: Box::new(self.identity_token_info.clone()), - change_item: self.change_item.clone(), - signing_key: self.signing_key.clone().expect("Signing key must be set"), - public_note: if self.group_action_id.is_some() { - None - } else { - self.public_note.clone() + if let Some(signing_key) = + validate_signing_key(&self.app_context, self.signing_key.as_ref()) + { + self.update_status = UpdateTokenConfigStatus::Updating; + let handle = MessageBanner::set_global( + ui.ctx(), + "Updating token configuration...", + MessageType::Info, + ); + handle.with_elapsed(); + self.refresh_banner = Some(handle); + action |= AppAction::BackendTask(BackendTask::TokenTask(Box::new( + TokenTask::UpdateTokenConfig { + identity_token_info: Box::new(self.identity_token_info.clone()), + change_item: self.change_item.clone(), + signing_key, + public_note: if self.group_action_id.is_some() { + None + } else { + self.public_note.clone() + }, + group_info, }, - group_info, - }, - ))); + ))); + } } } @@ -917,38 +931,21 @@ impl UpdateTokenConfigScreen { } impl ScreenLike for UpdateTokenConfigScreen { - fn display_message(&mut self, message: &str, message_type: MessageType) { - match message_type { - MessageType::Error => { - self.backend_message = Some((message.to_string(), MessageType::Error, Utc::now())); - self.update_status = UpdateTokenConfigStatus::NotUpdating; - } - MessageType::Info => { - self.backend_message = Some((message.to_string(), MessageType::Info, Utc::now())); - } - _ => {} + fn display_message(&mut self, _message: &str, message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. + if matches!(message_type, MessageType::Error | MessageType::Warning) { + self.refresh_banner.take_and_clear(); + self.update_status = UpdateTokenConfigStatus::NotUpdating; } } fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { - if let BackendTaskSuccessResult::UpdatedTokenConfig(change_item, fee_result) = + if let BackendTaskSuccessResult::UpdatedTokenConfig(_change_item, fee_result) = backend_task_success_result { - self.completed_fee_result = Some(fee_result.clone()); - let fee_info = format!( - " (Fee: Estimated {} • Actual {})", - format_credits_as_dash(fee_result.estimated_fee), - format_credits_as_dash(fee_result.actual_fee) - ); - self.backend_message = Some(( - format!( - "Successfully updated token config item: {}{}", - change_item, fee_info - ), - MessageType::Success, - Utc::now(), - )); - self.update_status = UpdateTokenConfigStatus::NotUpdating; + self.refresh_banner.take_and_clear(); + self.completed_fee_result = Some(fee_result); + self.update_status = UpdateTokenConfigStatus::Complete; } } @@ -993,11 +990,10 @@ impl ScreenLike for UpdateTokenConfigScreen { // Central panel island_central_panel(ctx, |ui| { egui::ScrollArea::vertical().show(ui, |ui| { - if let Some(msg) = &self.backend_message - && msg.1 == MessageType::Success { - action |= self.show_success_screen(ui); - return; - } + if self.update_status == UpdateTokenConfigStatus::Complete { + action |= self.show_success_screen(ui); + return; + } ui.heading("Update Token Configuration"); ui.add_space(10.0); @@ -1052,7 +1048,7 @@ impl ScreenLike for UpdateTokenConfigScreen { // Possibly handle locked wallet scenario (similar to TransferTokens) if let Some(wallet) = &self.selected_wallet { if let Err(e) = try_open_wallet_no_password(wallet) { - self.error_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } if wallet_needs_unlock(wallet) { ui.add_space(10.0); @@ -1095,43 +1091,6 @@ impl ScreenLike for UpdateTokenConfigScreen { action |= self.render_token_config_updater(ui); - if let Some((msg, msg_type, _)) = self.backend_message.clone() { - ui.add_space(10.0); - match msg_type { - MessageType::Success => { - ui.colored_label(Color32::DARK_GREEN, &msg); - } - MessageType::Error | MessageType::Warning => { - let dark_mode = ui.ctx().style().visuals.dark_mode; - let error_color = DashColors::error_color(dark_mode); - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label(RichText::new(format!("Error: {}", msg)).color(error_color)); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.backend_message = None; - } - }); - }); - } - MessageType::Info => { - ui.label(&msg); - } - }; - } - - if self.update_status != UpdateTokenConfigStatus::NotUpdating { - ui.add_space(10.0); - if let UpdateTokenConfigStatus::Updating(start_time) = &self.update_status { - let elapsed = Utc::now().signed_duration_since(*start_time); - ui.label(format!("Updating... ({} seconds)", elapsed.num_seconds())); - } - } } }); // end of ScrollArea }); diff --git a/src/ui/tokens/view_token_claims_screen.rs b/src/ui/tokens/view_token_claims_screen.rs index adc713c16..a72489ec1 100644 --- a/src/ui/tokens/view_token_claims_screen.rs +++ b/src/ui/tokens/view_token_claims_screen.rs @@ -2,6 +2,7 @@ use crate::app::{AppAction, DesiredAppAction}; use crate::backend_task::document::DocumentTask; use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::context::AppContext; +use crate::ui::components::MessageBanner; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::tokens_subscreen_chooser_panel::add_tokens_subscreen_chooser_panel; @@ -28,7 +29,6 @@ pub enum FetchStatus { pub struct ViewTokenClaimsScreen { pub identity_token_basic_info: IdentityTokenBasicInfo, pub new_claims_query: DocumentQuery, - message: Option<(String, MessageType, DateTime)>, fetch_status: FetchStatus, pub app_context: Arc, claims: Vec, @@ -60,7 +60,6 @@ impl ViewTokenClaimsScreen { limit: 0, start: None, }, - message: None, fetch_status: FetchStatus::NotFetching, app_context: app_context.clone(), claims: vec![], @@ -70,19 +69,14 @@ impl ViewTokenClaimsScreen { impl ScreenLike for ViewTokenClaimsScreen { fn display_message(&mut self, message: &str, message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. match message_type { - MessageType::Success => { - self.message = Some((message.to_string(), MessageType::Success, Utc::now())); - } MessageType::Error | MessageType::Warning => { - self.message = Some((message.to_string(), message_type, Utc::now())); if message.contains("Error fetching documents") { self.fetch_status = FetchStatus::NotFetching; } } - MessageType::Info => { - self.message = Some((message.to_string(), MessageType::Info, Utc::now())); - } + _ => {} } } @@ -92,7 +86,11 @@ impl ScreenLike for ViewTokenClaimsScreen { self.claims = documents.into_iter().filter_map(|(_, doc)| doc).collect(); if self.claims.is_empty() { - self.display_message("No claims found", MessageType::Info); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "No claims found", + MessageType::Info, + ); } } } @@ -130,7 +128,6 @@ impl ScreenLike for ViewTokenClaimsScreen { // Central panel island_central_panel(ctx, |ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; ui.heading("View Token Claims"); ui.add_space(10.0); @@ -147,20 +144,7 @@ impl ScreenLike for ViewTokenClaimsScreen { self.fetch_status = FetchStatus::Fetching(Utc::now()) } - if let Some((msg, msg_type, _)) = &self.message { - ui.add_space(10.0); - match msg_type { - MessageType::Success => { - ui.colored_label(DashColors::success_color(dark_mode), msg); - } - MessageType::Error | MessageType::Warning => { - ui.colored_label(DashColors::error_color(dark_mode), msg); - } - MessageType::Info => { - ui.label(msg); - } - }; - } + // Message display is handled by the global MessageBanner if self.fetch_status != FetchStatus::NotFetching { ui.add_space(10.0); diff --git a/src/ui/tools/address_balance_screen.rs b/src/ui/tools/address_balance_screen.rs index e34a04907..165aeccae 100644 --- a/src/ui/tools/address_balance_screen.rs +++ b/src/ui/tools/address_balance_screen.rs @@ -2,13 +2,13 @@ use crate::app::AppAction; use crate::backend_task::platform_info::{PlatformInfoTaskRequestType, PlatformInfoTaskResult}; use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::context::AppContext; +use crate::ui::components::MessageBanner; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::tools_subscreen_chooser_panel::add_tools_subscreen_chooser_panel; use crate::ui::components::top_panel::add_top_panel; -use crate::ui::theme::DashColors; use crate::ui::{MessageType, ScreenLike}; -use eframe::egui::{self, Context, Frame, Margin, RichText, ScrollArea, TextEdit, Ui}; +use eframe::egui::{self, Context, ScrollArea, TextEdit, Ui}; use std::sync::Arc; pub struct AddressBalanceScreen { @@ -16,7 +16,6 @@ pub struct AddressBalanceScreen { address_input: String, is_loading: bool, result: Option, - error_message: Option, } #[derive(Clone, Debug)] @@ -33,19 +32,21 @@ impl AddressBalanceScreen { address_input: String::new(), is_loading: false, result: None, - error_message: None, } } fn trigger_fetch(&mut self) -> AppAction { let address = self.address_input.trim().to_string(); if address.is_empty() { - self.error_message = Some("Please enter an address".to_string()); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Please enter an address", + MessageType::Error, + ); return AppAction::None; } self.is_loading = true; - self.error_message = None; self.result = None; let task = @@ -92,26 +93,6 @@ impl AddressBalanceScreen { } fn render_result(&mut self, ui: &mut Ui) { - if let Some(ref error) = self.error_message { - ui.add_space(20.0); - let error_color = DashColors::ERROR; - let error = error.clone(); - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label(RichText::new(format!("Error: {}", error)).color(error_color)); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.error_message = None; - } - }); - }); - } - if let Some(ref result) = self.result { ui.add_space(20.0); ui.separator(); @@ -143,9 +124,10 @@ impl AddressBalanceScreen { } impl ScreenLike for AddressBalanceScreen { - fn display_message(&mut self, message: &str, message_type: MessageType) { - if message_type == MessageType::Error { - self.error_message = Some(message.to_string()); + fn display_message(&mut self, _message: &str, message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. + if matches!(message_type, MessageType::Error | MessageType::Warning) { + self.is_loading = false; } } diff --git a/src/ui/tools/contract_visualizer_screen.rs b/src/ui/tools/contract_visualizer_screen.rs index 816b5242c..ecc7a6915 100644 --- a/src/ui/tools/contract_visualizer_screen.rs +++ b/src/ui/tools/contract_visualizer_screen.rs @@ -172,10 +172,10 @@ impl ContractVisualizerScreen { // ======================= 2. ScreenLike impl ======================= impl crate::ui::ScreenLike for ContractVisualizerScreen { - fn display_message(&mut self, msg: &str, t: crate::ui::MessageType) { - if matches!(t, crate::ui::MessageType::Error) { - self.parse_status = ContractParseStatus::Error(msg.to_owned()); - } + fn display_message(&mut self, _msg: &str, _t: crate::ui::MessageType) { + // INTENTIONAL: These screens perform only local synchronous parsing. + // No backend tasks are dispatched, so no error/success messages arrive here. + // Local parse errors are set directly via self.parse_status. } fn display_task_result(&mut self, _r: BackendTaskSuccessResult) {} fn ui(&mut self, ctx: &Context) -> AppAction { diff --git a/src/ui/tools/document_visualizer_screen.rs b/src/ui/tools/document_visualizer_screen.rs index 0997f74ae..bd2bc5633 100644 --- a/src/ui/tools/document_visualizer_screen.rs +++ b/src/ui/tools/document_visualizer_screen.rs @@ -194,10 +194,10 @@ impl DocumentVisualizerScreen { // ======================= 2. ScreenLike impl ======================= impl crate::ui::ScreenLike for DocumentVisualizerScreen { - fn display_message(&mut self, msg: &str, t: crate::ui::MessageType) { - if matches!(t, crate::ui::MessageType::Error) { - self.parse_status = DocumentParseStatus::Error(msg.to_owned()); - } + fn display_message(&mut self, _msg: &str, _t: crate::ui::MessageType) { + // INTENTIONAL: These screens perform only local synchronous parsing. + // No backend tasks are dispatched, so no error/success messages arrive here. + // Local parse errors are set directly via self.parse_status. } fn display_task_result(&mut self, _r: BackendTaskSuccessResult) {} fn ui(&mut self, ctx: &Context) -> AppAction { diff --git a/src/ui/tools/grovestark_screen.rs b/src/ui/tools/grovestark_screen.rs index e835a9db7..a1e6944a9 100644 --- a/src/ui/tools/grovestark_screen.rs +++ b/src/ui/tools/grovestark_screen.rs @@ -3,13 +3,14 @@ use crate::backend_task::BackendTask; use crate::backend_task::grovestark::GroveSTARKTask; use crate::context::AppContext; use crate::model::qualified_identity::{PrivateKeyTarget, QualifiedIdentity}; -use crate::ui::RootScreenType; use crate::ui::ScreenLike; +use crate::ui::components::MessageBanner; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::tools_subscreen_chooser_panel::add_tools_subscreen_chooser_panel; use crate::ui::components::top_panel::add_top_panel; use crate::ui::theme::{DashColors, Shape, Spacing, Typography}; +use crate::ui::{MessageType, RootScreenType}; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dash_sdk::dpp::identity::{ @@ -69,10 +70,6 @@ pub struct GroveSTARKScreen { proof_text: String, is_verifying: bool, verification_result: Option, - - // Error handling - gen_error_message: Option, - verify_error_message: Option, } impl GroveSTARKScreen { @@ -151,8 +148,6 @@ impl GroveSTARKScreen { proof_text: String::new(), is_verifying: false, verification_result: None, - gen_error_message: None, - verify_error_message: None, } } @@ -270,9 +265,10 @@ impl GroveSTARKScreen { fn generate_proof(&mut self, app_context: &AppContext) -> AppAction { if cfg!(debug_assertions) { - self.gen_error_message = Some( - "GroveSTARK proof generation requires a release build (cargo run --release)." - .to_string(), + MessageBanner::set_global( + app_context.egui_ctx(), + "GroveSTARK proof generation requires a release build (cargo run --release).", + MessageType::Error, ); self.is_generating = false; return AppAction::None; @@ -280,7 +276,6 @@ impl GroveSTARKScreen { // Reset any prior messages/results before starting a new generation self.is_generating = true; - self.gen_error_message = None; self.generated_proof = None; self.proof_size = None; self.generation_time = None; @@ -297,7 +292,11 @@ impl GroveSTARKScreen { id.clone() } None => { - self.gen_error_message = Some("No identity selected".to_string()); + MessageBanner::set_global( + app_context.egui_ctx(), + "No identity selected", + MessageType::Error, + ); self.is_generating = false; return AppAction::None; } @@ -306,7 +305,11 @@ impl GroveSTARKScreen { let selected_key = match &self.selected_key { Some(key) => key, None => { - self.gen_error_message = Some("No key selected".to_string()); + MessageBanner::set_global( + app_context.egui_ctx(), + "No key selected", + MessageType::Error, + ); self.is_generating = false; return AppAction::None; } @@ -322,7 +325,11 @@ impl GroveSTARKScreen { id.clone() } None => { - self.gen_error_message = Some("No contract selected".to_string()); + MessageBanner::set_global( + app_context.egui_ctx(), + "No contract selected", + MessageType::Error, + ); self.is_generating = false; return AppAction::None; } @@ -334,7 +341,11 @@ impl GroveSTARKScreen { doc_type.clone() } None => { - self.gen_error_message = Some("No document type selected".to_string()); + MessageBanner::set_global( + app_context.egui_ctx(), + "No document type selected", + MessageType::Error, + ); self.is_generating = false; return AppAction::None; } @@ -350,7 +361,11 @@ impl GroveSTARKScreen { id.clone() } None => { - self.gen_error_message = Some("No document selected".to_string()); + MessageBanner::set_global( + app_context.egui_ctx(), + "No document selected", + MessageType::Error, + ); self.is_generating = false; return AppAction::None; } @@ -374,20 +389,31 @@ impl GroveSTARKScreen { ) { Ok(Some((_, private_key_bytes))) => private_key_bytes, Ok(None) => { - self.gen_error_message = - Some("Private key not found in storage".to_string()); + MessageBanner::set_global( + app_context.egui_ctx(), + "Private key not found in storage", + MessageType::Error, + ); self.is_generating = false; return AppAction::None; } Err(e) => { - self.gen_error_message = Some(format!("Failed to get private key: {}", e)); + MessageBanner::set_global( + app_context.egui_ctx(), + format!("Failed to get private key: {}", e), + MessageType::Error, + ); self.is_generating = false; return AppAction::None; } } } None => { - self.gen_error_message = Some("Qualified identity not found".to_string()); + MessageBanner::set_global( + app_context.egui_ctx(), + "Qualified identity not found", + MessageType::Error, + ); self.is_generating = false; return AppAction::None; } @@ -416,18 +442,18 @@ impl GroveSTARKScreen { AppAction::BackendTask(task) } - fn verify_proof(&mut self, _app_context: &AppContext) -> AppAction { + fn verify_proof(&mut self, app_context: &AppContext) -> AppAction { if cfg!(debug_assertions) { - self.verify_error_message = Some( - "GroveSTARK proof verification requires a release build (cargo run --release)." - .to_string(), + MessageBanner::set_global( + app_context.egui_ctx(), + "GroveSTARK proof verification requires a release build (cargo run --release).", + MessageType::Error, ); self.is_verifying = false; return AppAction::None; } self.is_verifying = true; - self.verify_error_message = None; self.verification_result = None; // Clear any previous results // Parse the proof from pasted text @@ -448,7 +474,11 @@ impl GroveSTARKScreen { AppAction::BackendTask(task) } Err(e) => { - self.verify_error_message = Some(format!("Failed to parse proof: {}", e)); + MessageBanner::set_global( + app_context.egui_ctx(), + format!("Failed to parse proof: {}", e), + MessageType::Error, + ); self.is_verifying = false; AppAction::None } @@ -804,25 +834,7 @@ impl GroveSTARKScreen { return action; } - // Error Display - if let Some(error) = &self.gen_error_message { - let error_color = DashColors::ERROR; - let error = error.clone(); - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label(RichText::new(format!("Error: {}", error)).color(error_color)); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.gen_error_message = None; - } - }); - }); - } + // Error display is handled by the global MessageBanner. // Success Display if let Some(_proof) = &self.generated_proof { @@ -884,25 +896,7 @@ impl GroveSTARKScreen { ui.separator(); - // Error Display (above the button) - if let Some(error) = &self.verify_error_message { - let error_color = DashColors::ERROR; - let error = error.clone(); - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label(RichText::new(format!("Error: {}", error)).color(error_color)); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.verify_error_message = None; - } - }); - }); - } + // Error display is handled by the global MessageBanner. // Verify Button let can_verify = !self.proof_text.is_empty(); @@ -1000,13 +994,12 @@ impl ScreenLike for GroveSTARKScreen { self.refresh_contracts(&app_context); } - fn display_message(&mut self, message: &str, message_type: crate::ui::MessageType) { - // Only record errors and scope them to the active mode - if message_type == crate::ui::MessageType::Error { - match self.mode { - ProofMode::Generate => self.gen_error_message = Some(message.to_string()), - ProofMode::Verify => self.verify_error_message = Some(message.to_string()), - } + fn display_message(&mut self, _message: &str, message_type: crate::ui::MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. + if matches!( + message_type, + crate::ui::MessageType::Error | crate::ui::MessageType::Warning + ) { self.is_generating = false; self.is_verifying = false; } @@ -1034,7 +1027,6 @@ impl ScreenLike for GroveSTARKScreen { self.generation_time = Some(std::time::Duration::from_millis( proof_data.metadata.generation_time_ms, )); - self.gen_error_message = None; } BackendTaskSuccessResult::VerifiedZKProof(is_valid, proof_data) => { self.is_verifying = false; @@ -1058,7 +1050,6 @@ impl ScreenLike for GroveSTARKScreen { if is_valid { "VALID" } else { "INVALID" } ), }); - self.verify_error_message = None; } _ => {} } diff --git a/src/ui/tools/masternode_list_diff_screen.rs b/src/ui/tools/masternode_list_diff_screen.rs index 41978be75..5e30a55b3 100644 --- a/src/ui/tools/masternode_list_diff_screen.rs +++ b/src/ui/tools/masternode_list_diff_screen.rs @@ -40,7 +40,7 @@ use dash_sdk::dpp::dashcore::{ }; use dash_sdk::dpp::prelude::CoreBlockHeight; use eframe::egui::{self, Context, ScrollArea, Ui}; -use egui::{Align, Color32, Frame, Layout, Margin, RichText, Stroke, TextEdit, Vec2}; +use egui::{Align, Frame, Layout, Margin, RichText, Stroke, TextEdit, Vec2}; use itertools::Itertools; use rfd::FileDialog; use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; @@ -69,7 +69,6 @@ struct InputState { struct UiState { selected_tab: usize, show_popup_for_render_masternode_list_engine: bool, - message: Option<(String, MessageType)>, error: Option, } @@ -1163,7 +1162,6 @@ impl MasternodeListDiffScreen { self.task.queued_task = None; self.input.search_term = None; self.ui_state.error = None; - self.ui_state.message = None; } /// Clear all data except the oldest MNList diff starting from height 0 @@ -1207,7 +1205,6 @@ impl MasternodeListDiffScreen { self.selection.selected_quorum_hash_in_mnlist_diff = None; self.selection.selected_masternode_pro_tx_hash = None; self.data.qr_infos = Default::default(); - self.ui_state.message = None; // Clear chain lock signatures caches as these are independent of the retained base diff self.cache.chain_lock_sig_cache.clear(); self.cache.chain_lock_reversed_sig_cache.clear(); @@ -1557,38 +1554,6 @@ impl MasternodeListDiffScreen { action } - fn render_message_banner(&mut self, ui: &mut Ui) { - let Some((msg, msg_type)) = self.ui_state.message.clone() else { - return; - }; - - let dark_mode = ui.ctx().style().visuals.dark_mode; - let message_color = match msg_type { - MessageType::Error => DashColors::ERROR, - MessageType::Warning => DashColors::WARNING, - MessageType::Info => DashColors::text_primary(dark_mode), - // Dark green for success text - MessageType::Success => Color32::DARK_GREEN, - }; - ui.horizontal(|ui| { - Frame::new() - .fill(message_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, message_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label(RichText::new(msg).color(message_color)); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.ui_state.message = None; - } - }); - }); - }); - ui.add_space(10.0); - } - fn render_error_banner(&mut self, ui: &mut Ui) { let Some(error_msg) = self.ui_state.error.clone() else { return; @@ -4181,17 +4146,10 @@ impl MasternodeListDiffScreen { impl ScreenLike for MasternodeListDiffScreen { fn display_message(&mut self, message: &str, message_type: MessageType) { - match message_type { - MessageType::Error | MessageType::Warning => { - self.task.pending = None; - self.ui_state.error = Some(message.to_string()); - } - MessageType::Success => { - self.ui_state.message = Some((message.to_string(), message_type)); - } - MessageType::Info => { - // Do not show transient info messages to avoid noisy black text banners. - } + // Banner display is handled globally by AppState; this is only for side-effects. + if matches!(message_type, MessageType::Error | MessageType::Warning) { + self.task.pending = None; + self.ui_state.error = Some(message.to_string()); } } @@ -4433,7 +4391,6 @@ impl ScreenLike for MasternodeListDiffScreen { inner |= AppAction::BackendTask(task); } - self.render_message_banner(ui); self.render_error_banner(ui); self.render_pending_status(ui); diff --git a/src/ui/tools/platform_info_screen.rs b/src/ui/tools/platform_info_screen.rs index d85be3359..22a5d0b85 100644 --- a/src/ui/tools/platform_info_screen.rs +++ b/src/ui/tools/platform_info_screen.rs @@ -10,7 +10,7 @@ use crate::ui::theme::DashColors; use crate::ui::{MessageType, RootScreenType, ScreenLike}; use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::version::PlatformVersion; -use eframe::egui::{self, Context, Frame, Margin, RichText, ScrollArea, Ui}; +use eframe::egui::{self, Context, ScrollArea, Ui}; use std::sync::Arc; pub struct PlatformInfoScreen { @@ -21,7 +21,6 @@ pub struct PlatformInfoScreen { current_result: Option, current_result_title: Option, active_tasks: std::collections::HashSet, - error_message: Option, } impl PlatformInfoScreen { @@ -34,7 +33,6 @@ impl PlatformInfoScreen { current_result: None, current_result_title: None, active_tasks: std::collections::HashSet::new(), - error_message: None, } } @@ -45,7 +43,6 @@ impl PlatformInfoScreen { ) -> AppAction { if !self.active_tasks.contains(task_name) { self.active_tasks.insert(task_name.to_string()); - self.error_message = None; let task = BackendTask::PlatformInfo(task_type); return AppAction::BackendTask(task); } @@ -124,27 +121,6 @@ impl PlatformInfoScreen { return; } - // Check for errors and display them in the results area - if let Some(error) = &self.error_message { - let error_color = DashColors::ERROR; - let error = error.clone(); - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label(RichText::new(format!("Error: {}", error)).color(error_color)); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.error_message = None; - } - }); - }); - return; - } - // Display normal results if let Some(result) = &self.current_result { if let Some(title) = &self.current_result_title { @@ -171,7 +147,6 @@ impl ScreenLike for PlatformInfoScreen { self.current_result = None; self.current_result_title = None; self.active_tasks.clear(); - self.error_message = None; } fn refresh_on_arrival(&mut self) { @@ -242,10 +217,9 @@ impl ScreenLike for PlatformInfoScreen { action } - fn display_message(&mut self, message: &str, message_type: MessageType) { - if message_type == MessageType::Error { - self.error_message = Some(message.to_string()); - // Clear loading states for all tasks + fn display_message(&mut self, _message: &str, message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. + if matches!(message_type, MessageType::Error | MessageType::Warning) { self.active_tasks.clear(); } } @@ -290,7 +264,7 @@ impl ScreenLike for PlatformInfoScreen { self.current_result = Some(basic_info); self.current_result_title = Some("Basic Platform Information".to_string()); self.active_tasks.remove("basic_info"); - self.error_message = None; + // Error state cleared on success } PlatformInfoTaskResult::TextResult(text) => { // Find which task this result is for and set the title appropriately @@ -316,7 +290,7 @@ impl ScreenLike for PlatformInfoScreen { self.current_result = Some(text); self.current_result_title = Some(title); self.active_tasks.clear(); // Clear any remaining active tasks - self.error_message = None; + // Error state cleared on success } PlatformInfoTaskResult::AddressBalance { .. } => { // This result is handled by AddressBalanceScreen, not here diff --git a/src/ui/tools/transition_visualizer_screen.rs b/src/ui/tools/transition_visualizer_screen.rs index f890997f6..4e5382974 100644 --- a/src/ui/tools/transition_visualizer_screen.rs +++ b/src/ui/tools/transition_visualizer_screen.rs @@ -3,6 +3,7 @@ use crate::backend_task::BackendTask; use crate::backend_task::contract::ContractTask; use crate::context::AppContext; use crate::ui::components::left_panel::add_left_panel; +use crate::ui::components::message_banner::{BannerHandle, MessageBanner, OptionBannerExt}; use crate::ui::components::styled::island_central_panel; use crate::ui::components::tools_subscreen_chooser_panel::add_tools_subscreen_chooser_panel; use crate::ui::components::top_panel::add_top_panel; @@ -12,7 +13,6 @@ use crate::ui::{MessageType, RootScreenType, ScreenLike}; use base64::{Engine, engine::general_purpose::STANDARD}; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::platform_value::string_encoding::Encoding; -use dash_sdk::dpp::prelude::TimestampMillis; use dash_sdk::dpp::serialization::PlatformDeserializable; use dash_sdk::dpp::state_transition::StateTransition; use dash_sdk::platform::Identifier; @@ -20,12 +20,12 @@ use eframe::egui::{self, Color32, Context, ScrollArea, TextEdit, Ui, Window}; use egui::RichText; use serde_json::Value; use std::sync::Arc; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use std::time::{Duration, Instant}; #[derive(PartialEq)] enum TransitionBroadcastStatus { NotStarted, - Submitting(TimestampMillis), + Submitting, Error(String, Instant), Complete(Instant), } @@ -35,6 +35,7 @@ pub struct TransitionVisualizerScreen { input_data: String, parsed_json: Option, broadcast_status: TransitionBroadcastStatus, + submit_banner: Option, show_contract_dialog: bool, selected_contract_id: Option, detected_contract_ids: Vec, @@ -48,6 +49,7 @@ impl TransitionVisualizerScreen { input_data: String::new(), parsed_json: None, broadcast_status: TransitionBroadcastStatus::NotStarted, + submit_banner: None, show_contract_dialog: false, selected_contract_id: None, detected_contract_ids: Vec::new(), @@ -235,11 +237,15 @@ impl TransitionVisualizerScreen { if ui.add(button).clicked() { // Mark as submitting - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - self.broadcast_status = TransitionBroadcastStatus::Submitting(now); + self.submit_banner.take_and_clear(); + let handle = MessageBanner::set_global( + ui.ctx(), + "Submitting transition...", + MessageType::Info, + ); + handle.with_elapsed(); + self.submit_banner = Some(handle); + self.broadcast_status = TransitionBroadcastStatus::Submitting; if let Some(json) = &self.parsed_json && let Ok(state_transition) = serde_json::from_str(json) @@ -262,35 +268,8 @@ impl TransitionVisualizerScreen { ui.add_space(5.0); match &self.broadcast_status { TransitionBroadcastStatus::NotStarted => {} - TransitionBroadcastStatus::Submitting(start_time) => { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let elapsed_seconds = now - start_time; - - let display_time = if elapsed_seconds < 60 { - format!( - "{} second{}", - elapsed_seconds, - if elapsed_seconds == 1 { "" } else { "s" } - ) - } else { - let minutes = elapsed_seconds / 60; - let seconds = elapsed_seconds % 60; - format!( - "{} minute{} and {} second{}", - minutes, - if minutes == 1 { "" } else { "s" }, - seconds, - if seconds == 1 { "" } else { "s" } - ) - }; - - ui.label(format!( - "Broadcasting... Time taken so far: {}", - display_time - )); + TransitionBroadcastStatus::Submitting => { + // Elapsed time is shown in the global banner } TransitionBroadcastStatus::Error(msg, timestamp) => { let elapsed = timestamp.elapsed(); @@ -403,24 +382,20 @@ impl TransitionVisualizerScreen { } impl ScreenLike for TransitionVisualizerScreen { - fn display_message(&mut self, message: &str, message_type: MessageType) { + fn display_message(&mut self, _message: &str, message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. match message_type { MessageType::Success => { - // Only update broadcast status if we're actually broadcasting - if matches!( - self.broadcast_status, - TransitionBroadcastStatus::Submitting(_) - ) { + if matches!(self.broadcast_status, TransitionBroadcastStatus::Submitting) { + self.submit_banner.take_and_clear(); self.broadcast_status = TransitionBroadcastStatus::Complete(Instant::now()); } } MessageType::Error | MessageType::Warning => { - self.broadcast_status = - TransitionBroadcastStatus::Error(message.to_string(), Instant::now()); - } - MessageType::Info => { - // Could do nothing or handle info + self.submit_banner.take_and_clear(); + self.broadcast_status = TransitionBroadcastStatus::NotStarted; } + MessageType::Info => {} } } diff --git a/src/ui/wallets/asset_lock_detail_screen.rs b/src/ui/wallets/asset_lock_detail_screen.rs index 53abb04a3..5f7f943d9 100644 --- a/src/ui/wallets/asset_lock_detail_screen.rs +++ b/src/ui/wallets/asset_lock_detail_screen.rs @@ -1,13 +1,13 @@ use crate::app::AppAction; use crate::context::AppContext; use crate::model::wallet::Wallet; +use crate::ui::components::MessageBanner; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; use crate::ui::theme::DashColors; use crate::ui::{MessageType, RootScreenType, ScreenLike}; -use chrono::{DateTime, Utc}; use dash_sdk::dashcore_rpc::dashcore::{Address, InstantLock, Transaction}; use dash_sdk::dpp::fee::Credits; use dash_sdk::dpp::prelude::AssetLockProof; @@ -19,11 +19,9 @@ pub struct AssetLockDetailScreen { pub wallet_seed_hash: [u8; 32], pub asset_lock_index: usize, pub app_context: Arc, - message: Option<(String, MessageType, DateTime)>, wallet: Option>>, wallet_password: String, show_password: bool, - error_message: Option, show_private_key_popup: bool, private_key_wif: Option, } @@ -47,11 +45,9 @@ impl AssetLockDetailScreen { wallet_seed_hash, asset_lock_index, app_context: app_context.clone(), - message: None, wallet, wallet_password: String::new(), show_password: false, - error_message: None, show_private_key_popup: false, private_key_wif: None, } @@ -189,7 +185,7 @@ impl AssetLockDetailScreen { ui.label("Asset Lock Proof (hex):"); if ui.small_button("Copy").clicked() { ui.ctx().copy_text(proof_hex.clone()); - self.display_message("Asset lock proof copied to clipboard", MessageType::Success); + MessageBanner::set_global(ui.ctx(), "Asset lock proof copied to clipboard", MessageType::Success); } }); ui.add_space(5.0); @@ -235,7 +231,7 @@ impl AssetLockDetailScreen { self.show_private_key_popup = true; } Err(e) => { - self.display_message(&format!("Error retrieving private key: {}", e), MessageType::Error); + MessageBanner::set_global(ui.ctx(), format!("Error retrieving private key: {}", e), MessageType::Error); } } } @@ -262,17 +258,6 @@ impl AssetLockDetailScreen { }); } } - - fn check_message_expiration(&mut self) { - if let Some((_, _, timestamp)) = &self.message { - let now = Utc::now(); - let elapsed = now.signed_duration_since(*timestamp); - - if elapsed.num_seconds() >= 10 { - self.message = None; - } - } - } } impl ScreenWithWalletUnlock for AssetLockDetailScreen { @@ -296,14 +281,6 @@ impl ScreenWithWalletUnlock for AssetLockDetailScreen { &mut self.show_password } - fn set_error_message(&mut self, error_message: Option) { - self.error_message = error_message; - } - - fn error_message(&self) -> Option<&String> { - self.error_message.as_ref() - } - fn app_context(&self) -> Arc { self.app_context.clone() } @@ -311,8 +288,6 @@ impl ScreenWithWalletUnlock for AssetLockDetailScreen { impl ScreenLike for AssetLockDetailScreen { fn ui(&mut self, ctx: &Context) -> AppAction { - self.check_message_expiration(); - let mut action = add_top_panel( ctx, &self.app_context, @@ -360,28 +335,7 @@ impl ScreenLike for AssetLockDetailScreen { self.render_asset_lock_info(ui); }); - // Display messages - if let Some((message, message_type, timestamp)) = &self.message { - let message_color = match message_type { - MessageType::Error => egui::Color32::DARK_RED, - MessageType::Warning => DashColors::warning_color(dark_mode), - MessageType::Info => DashColors::text_primary(dark_mode), - MessageType::Success => egui::Color32::DARK_GREEN, - }; - - ui.add_space(25.0); - ui.horizontal(|ui| { - ui.add_space(10.0); - - let now = Utc::now(); - let elapsed = now.signed_duration_since(*timestamp); - let remaining = (10 - elapsed.num_seconds()).max(0); - - let full_msg = format!("{} ({}s)", message, remaining); - ui.label(egui::RichText::new(full_msg).color(message_color)); - }); - ui.add_space(2.0); - } + // Message display is handled by the global MessageBanner inner_action }); @@ -420,7 +374,7 @@ impl ScreenLike for AssetLockDetailScreen { ui.horizontal(|ui| { if ui.button("Copy").clicked() { ui.ctx().copy_text(wif.clone()); - self.display_message("Private key copied to clipboard", MessageType::Success); + MessageBanner::set_global(ctx, "Private key copied to clipboard", MessageType::Success); } if ui.button("Close").clicked() { self.show_private_key_popup = false; @@ -435,8 +389,8 @@ impl ScreenLike for AssetLockDetailScreen { action } - fn display_message(&mut self, message: &str, message_type: MessageType) { - self.message = Some((message.to_string(), message_type, Utc::now())); + fn display_message(&mut self, _message: &str, _message_type: MessageType) { + // Error/success display is handled by the global MessageBanner. } fn refresh_on_arrival(&mut self) {} diff --git a/src/ui/wallets/create_asset_lock_screen.rs b/src/ui/wallets/create_asset_lock_screen.rs index b0d28d310..4f6b3aaba 100644 --- a/src/ui/wallets/create_asset_lock_screen.rs +++ b/src/ui/wallets/create_asset_lock_screen.rs @@ -6,6 +6,7 @@ use crate::model::amount::Amount; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; use crate::ui::components::Component; +use crate::ui::components::MessageBanner; use crate::ui::components::amount_input::AmountInput; use crate::ui::components::identity_selector::IdentitySelector; use crate::ui::components::left_panel::add_left_panel; @@ -15,7 +16,6 @@ use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; use crate::ui::identities::funding_common::{self, WalletFundedScreenStep, generate_qr_code_image}; use crate::ui::theme::DashColors; use crate::ui::{MessageType, RootScreenType, ScreenLike}; -use chrono::{DateTime, Utc}; use dash_sdk::dashcore_rpc::RpcApi; use dash_sdk::dashcore_rpc::dashcore::{Address, OutPoint, TxOut}; use eframe::egui::{self, Context, Ui}; @@ -35,11 +35,8 @@ pub struct CreateAssetLockScreen { pub wallet: Arc>, selected_wallet: Option>>, pub app_context: Arc, - message: Option<(String, MessageType, DateTime)>, wallet_password: String, show_password: bool, - error_message: Option, - // Asset lock creation fields step: Arc>, amount_input: Option, @@ -78,10 +75,8 @@ impl CreateAssetLockScreen { wallet, selected_wallet, app_context: app_context.clone(), - message: None, wallet_password: String::new(), show_password: false, - error_message: None, step: Arc::new(RwLock::new(WalletFundedScreenStep::WaitingOnFunds)), amount_input: Some( AmountInput::new(Amount::new_dash(0.5)) @@ -182,23 +177,16 @@ impl CreateAssetLockScreen { if ui.button("Copy Address").clicked() { ui.ctx().copy_text(dash_uri.clone()); - self.display_message("Address copied to clipboard", MessageType::Success); + MessageBanner::set_global( + ui.ctx(), + "Address copied to clipboard", + MessageType::Success, + ); } Ok(()) } - fn check_message_expiration(&mut self) { - if let Some((_, _, timestamp)) = &self.message { - let now = Utc::now(); - let elapsed = now.signed_duration_since(*timestamp); - - if elapsed.num_seconds() >= 10 { - self.message = None; - } - } - } - fn show_success(&mut self, ui: &mut Ui) -> AppAction { let mut action = AppAction::None; @@ -251,7 +239,6 @@ impl CreateAssetLockScreen { self.funding_utxo = None; self.core_has_funding_address = None; self.asset_lock_tx_id = None; - self.error_message = None; self.show_advanced_options = false; *self.step.write().unwrap() = WalletFundedScreenStep::WaitingOnFunds; } @@ -284,14 +271,6 @@ impl ScreenWithWalletUnlock for CreateAssetLockScreen { &mut self.show_password } - fn set_error_message(&mut self, error_message: Option) { - self.error_message = error_message; - } - - fn error_message(&self) -> Option<&String> { - self.error_message.as_ref() - } - fn app_context(&self) -> Arc { self.app_context.clone() } @@ -299,8 +278,6 @@ impl ScreenWithWalletUnlock for CreateAssetLockScreen { impl ScreenLike for CreateAssetLockScreen { fn ui(&mut self, ctx: &Context) -> AppAction { - self.check_message_expiration(); - let wallet_name = self .wallet .read() @@ -609,16 +586,11 @@ impl ScreenLike for CreateAssetLockScreen { egui::Layout::top_down(egui::Align::Min).with_cross_align(egui::Align::Center), |ui| { if let Err(e) = self.render_qr_code(ui) { - self.error_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } ui.add_space(20.0); - if let Some(error_message) = self.error_message.as_ref() { - ui.colored_label(egui::Color32::DARK_RED, error_message); - ui.add_space(20.0); - } - match step { WalletFundedScreenStep::WaitingOnFunds => { ui.heading(RichText::new("Waiting for funds...").color(DashColors::text_primary(dark_mode))); @@ -652,21 +624,21 @@ impl ScreenLike for CreateAssetLockScreen { CoreTask::CreateTopUpAssetLock(self.wallet.clone(), credits, identity_index, self.top_up_index) )) } else { - self.error_message = Some("Selected identity has no wallet index".to_string()); + MessageBanner::set_global(ui.ctx(), "Selected identity has no wallet index", MessageType::Error); AppAction::None } } else { - self.error_message = Some("No identity selected for top-up".to_string()); + MessageBanner::set_global(ui.ctx(), "No identity selected for top-up", MessageType::Error); AppAction::None } } None => { - self.error_message = Some("No purpose selected".to_string()); + MessageBanner::set_global(ui.ctx(), "No purpose selected", MessageType::Error); AppAction::None } } } else { - self.error_message = Some("No amount specified".to_string()); + MessageBanner::set_global(ui.ctx(), "No amount specified", MessageType::Error); AppAction::None } } @@ -690,28 +662,7 @@ impl ScreenLike for CreateAssetLockScreen { } }); - // Display messages - if let Some((message, message_type, timestamp)) = &self.message { - let message_color = match message_type { - MessageType::Error => egui::Color32::DARK_RED, - MessageType::Warning => DashColors::warning_color(dark_mode), - MessageType::Info => DashColors::text_primary(dark_mode), - MessageType::Success => egui::Color32::DARK_GREEN, - }; - - ui.add_space(25.0); - ui.horizontal(|ui| { - ui.add_space(10.0); - - let now = Utc::now(); - let elapsed = now.signed_duration_since(*timestamp); - let remaining = (10 - elapsed.num_seconds()).max(0); - - let full_msg = format!("{} ({}s)", message, remaining); - ui.label(egui::RichText::new(full_msg).color(message_color)); - }); - ui.add_space(2.0); - } + // Message display is handled by the global MessageBanner inner_action }); @@ -719,8 +670,8 @@ impl ScreenLike for CreateAssetLockScreen { action } - fn display_message(&mut self, message: &str, message_type: MessageType) { - self.message = Some((message.to_string(), message_type, Utc::now())); + fn display_message(&mut self, _message: &str, _message_type: MessageType) { + // Error/success display is handled by the global MessageBanner. } fn refresh_on_arrival(&mut self) { @@ -769,7 +720,8 @@ impl ScreenLike for CreateAssetLockScreen { let mut step = self.step.write().unwrap(); *step = WalletFundedScreenStep::Success; drop(step); - self.display_message( + MessageBanner::set_global( + self.app_context.egui_ctx(), "Asset lock created successfully!", MessageType::Success, ); @@ -784,7 +736,8 @@ impl ScreenLike for CreateAssetLockScreen { let mut step = self.step.write().unwrap(); *step = WalletFundedScreenStep::Success; drop(step); - self.display_message( + MessageBanner::set_global( + self.app_context.egui_ctx(), "Asset lock created successfully!", MessageType::Success, ); @@ -806,7 +759,8 @@ impl ScreenLike for CreateAssetLockScreen { let mut step = self.step.write().unwrap(); *step = WalletFundedScreenStep::Success; drop(step); - self.display_message( + MessageBanner::set_global( + self.app_context.egui_ctx(), "Asset lock created successfully!", MessageType::Success, ); @@ -821,7 +775,8 @@ impl ScreenLike for CreateAssetLockScreen { let mut step = self.step.write().unwrap(); *step = WalletFundedScreenStep::Success; drop(step); - self.display_message( + MessageBanner::set_global( + self.app_context.egui_ctx(), "Asset lock created successfully!", MessageType::Success, ); diff --git a/src/ui/wallets/send_screen.rs b/src/ui/wallets/send_screen.rs index e27300f80..6e7f7d5fa 100644 --- a/src/ui/wallets/send_screen.rs +++ b/src/ui/wallets/send_screen.rs @@ -14,6 +14,7 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; +use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt}; use crate::ui::theme::DashColors; use crate::ui::{MessageType, RootScreenType, ScreenLike}; use dash_sdk::dashcore_rpc::dashcore::Address; @@ -34,7 +35,6 @@ use eframe::egui::{self, Context, RichText, Ui}; use egui::{Color32, Frame, Margin}; use std::collections::BTreeMap; use std::sync::{Arc, RwLock}; -use std::time::{SystemTime, UNIX_EPOCH}; /// Maximum number of platform address inputs allowed per state transition const MAX_PLATFORM_INPUTS: usize = 16; @@ -283,12 +283,12 @@ pub enum SourceSelection { #[derive(Debug, Clone, PartialEq)] pub enum SendStatus { NotStarted, - /// Waiting for result, stores the start time in seconds since epoch - WaitingForResult(u64), + /// Waiting for result + WaitingForResult, /// Successfully completed with a success message Complete(String), - /// Error occurred - Error(String), + /// Error occurred (message displayed by global MessageBanner) + Error, } /// Fee strategy for platform transfers @@ -389,10 +389,10 @@ pub struct WalletSendScreen { // State send_status: SendStatus, + send_banner: Option, // Wallet unlock wallet_unlock_popup: WalletUnlockPopup, - error_message: Option, } impl WalletSendScreen { @@ -417,8 +417,8 @@ impl WalletSendScreen { fee_strategy: PlatformFeeStrategy::default(), subtract_fee: false, send_status: SendStatus::NotStarted, + send_banner: None, wallet_unlock_popup: WalletUnlockPopup::new(), - error_message: None, } } @@ -482,15 +482,8 @@ impl WalletSendScreen { self.send_status = SendStatus::NotStarted; } - fn now_epoch_secs() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs() - } - fn mark_sending(&mut self) { - self.send_status = SendStatus::WaitingForResult(Self::now_epoch_secs()); + self.send_status = SendStatus::WaitingForResult; } fn format_dash(amount_duffs: u64) -> String { @@ -657,6 +650,16 @@ impl WalletSendScreen { } } + /// Clear the current send banner and show a new "Sending transaction..." progress banner. + /// + /// Called before dispatching any send backend task so the elapsed counter always starts fresh. + fn set_send_progress_banner(&mut self, ctx: &Context) { + self.send_banner.take_and_clear(); + let handle = MessageBanner::set_global(ctx, "Sending transaction...", MessageType::Info); + handle.with_elapsed(); + self.send_banner = Some(handle); + } + /// Validate and execute the send based on detected types fn validate_and_send(&mut self) -> Result { let wallet = self.selected_wallet.as_ref().ok_or("No wallet selected")?; @@ -1059,7 +1062,7 @@ impl WalletSendScreen { }; if let Err(e) = try_open_wallet_no_password(wallet) { - self.error_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } if wallet_needs_unlock(wallet) { ui.add_space(10.0); @@ -1078,28 +1081,7 @@ impl WalletSendScreen { true } - fn format_elapsed_time(start_time: u64) -> String { - let elapsed_seconds = Self::now_epoch_secs().saturating_sub(start_time); - if elapsed_seconds < 60 { - format!( - "{} second{}", - elapsed_seconds, - if elapsed_seconds == 1 { "" } else { "s" } - ) - } else { - let minutes = elapsed_seconds / 60; - let seconds = elapsed_seconds % 60; - format!( - "{} minute{} {} second{}", - minutes, - if minutes == 1 { "" } else { "s" }, - seconds, - if seconds == 1 { "" } else { "s" } - ) - } - } - - fn render_send_status(&mut self, ui: &mut Ui, dark_mode: bool) -> Option { + fn render_send_status(&mut self, ui: &mut Ui) -> Option { match self.send_status.clone() { SendStatus::Complete(message) => { let mut action = AppAction::None; @@ -1121,43 +1103,21 @@ impl WalletSendScreen { }); Some(action) } - SendStatus::WaitingForResult(start_time) => { + SendStatus::WaitingForResult => { ui.vertical_centered(|ui| { ui.add_space(100.0); ui.add(egui::Spinner::new().size(40.0)); ui.add_space(20.0); ui.heading("Sending..."); - ui.add_space(10.0); - ui.label( - RichText::new(format!( - "Time elapsed: {}", - Self::format_elapsed_time(start_time) - )) - .color(DashColors::text_secondary(dark_mode)), - ); ui.add_space(100.0); }); Some(AppAction::None) } - SendStatus::Error(error_msg) => { - let mut dismiss = false; - ui.horizontal(|ui| { - Frame::new() - .fill(DashColors::ERROR.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, DashColors::ERROR)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label(RichText::new(&error_msg).color(DashColors::ERROR)); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - dismiss = true; - } - }); - }); - }); - if dismiss { + SendStatus::Error => { + // Error message is displayed by the global MessageBanner. + // Show a dismiss/retry option. + ui.add_space(10.0); + if ui.button("Dismiss").clicked() { self.send_status = SendStatus::NotStarted; } ui.add_space(10.0); @@ -1683,7 +1643,7 @@ impl WalletSendScreen { let has_amount = self.amount.as_ref().map(|a| a.value() > 0).unwrap_or(false); let has_source = self.selected_source.is_some(); - let is_sending = matches!(self.send_status, SendStatus::WaitingForResult(_)); + let is_sending = matches!(self.send_status, SendStatus::WaitingForResult); let can_send = wallet_open && !is_sending && has_destination && has_amount && has_source; ui.horizontal(|ui| { @@ -1711,10 +1671,12 @@ impl WalletSendScreen { if ui.add_enabled(can_send, send_button).clicked() { match self.validate_and_send() { Ok(send_action) => { + self.set_send_progress_banner(ui.ctx()); action = send_action; } Err(e) => { - self.display_message(&e, MessageType::Error); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + self.send_status = SendStatus::Error; } } } @@ -2242,7 +2204,7 @@ impl WalletSendScreen { .as_ref() .is_some_and(|w| w.read().map(|g| g.is_open()).unwrap_or(false)); - let is_sending = matches!(self.send_status, SendStatus::WaitingForResult(_)); + let is_sending = matches!(self.send_status, SendStatus::WaitingForResult); // Check if we have valid inputs based on source type let has_valid_inputs = match self.advanced_source_type { @@ -2287,10 +2249,12 @@ impl WalletSendScreen { if ui.add_enabled(can_send, send_button).clicked() { match self.validate_and_send_advanced() { Ok(send_action) => { + self.set_send_progress_banner(ui.ctx()); action = send_action; } Err(e) => { - self.display_message(&e, MessageType::Error); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + self.send_status = SendStatus::Error; } } } @@ -2624,7 +2588,7 @@ impl ScreenLike for WalletSendScreen { let mut inner_action = AppAction::None; let dark_mode = ui.ctx().style().visuals.dark_mode; - if let Some(status_action) = self.render_send_status(ui, dark_mode) { + if let Some(status_action) = self.render_send_status(ui) { return status_action; } @@ -2671,11 +2635,14 @@ impl ScreenLike for WalletSendScreen { } fn display_message(&mut self, message: &str, message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. match message_type { MessageType::Error | MessageType::Warning => { - self.send_status = SendStatus::Error(message.to_string()); + self.send_banner.take_and_clear(); + self.send_status = SendStatus::Error; } MessageType::Success => { + self.send_banner.take_and_clear(); self.send_status = SendStatus::Complete(message.to_string()); } MessageType::Info => { @@ -2688,6 +2655,7 @@ impl ScreenLike for WalletSendScreen { &mut self, backend_task_success_result: crate::backend_task::BackendTaskSuccessResult, ) { + self.send_banner.take_and_clear(); match backend_task_success_result { crate::backend_task::BackendTaskSuccessResult::WalletPayment { txid: _, diff --git a/src/ui/wallets/single_key_send_screen.rs b/src/ui/wallets/single_key_send_screen.rs index 9528d9020..1a7f3963a 100644 --- a/src/ui/wallets/single_key_send_screen.rs +++ b/src/ui/wallets/single_key_send_screen.rs @@ -6,12 +6,12 @@ use crate::backend_task::core::{CoreTask, PaymentRecipient, WalletPaymentRequest use crate::context::AppContext; use crate::model::amount::{Amount, DASH_DECIMAL_PLACES}; use crate::model::wallet::single_key::SingleKeyWallet; +use crate::ui::components::MessageBanner; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::top_panel::add_top_panel; use crate::ui::theme::DashColors; use crate::ui::{MessageType, RootScreenType, ScreenLike}; -use chrono::{DateTime, Utc}; use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::fee::FeeLevel; use eframe::egui::{self, Context, RichText, Ui}; use egui::{Color32, Frame, Margin}; @@ -60,12 +60,10 @@ pub struct SingleKeyWalletSendScreen { // State sending: bool, - message: Option<(String, MessageType, DateTime)>, // Wallet unlock wallet_password: String, show_password: bool, - error_message: Option, // Fee confirmation dialog fee_dialog: FeeConfirmationDialog, @@ -84,10 +82,8 @@ impl SingleKeyWalletSendScreen { subtract_fee: false, memo: String::new(), sending: false, - message: None, wallet_password: String::new(), show_password: false, - error_message: None, fee_dialog: FeeConfirmationDialog::default(), show_advanced_options: false, } @@ -783,27 +779,27 @@ impl SingleKeyWalletSendScreen { Ok(mut wallet_guard) => { match wallet_guard.open(&self.wallet_password) { Ok(_) => { - self.error_message = None; self.wallet_password.clear(); } Err(e) => { - self.error_message = - Some(format!("Failed to unlock: {}", e)); + MessageBanner::set_global( + ui.ctx(), + format!("Failed to unlock: {}", e), + MessageType::Error, + ); } } } Err(_) => { - self.error_message = - Some("Wallet lock error, please try again".to_string()); + MessageBanner::set_global( + ui.ctx(), + "Wallet lock error, please try again", + MessageType::Error, + ); } } } }); - - if let Some(error) = &self.error_message { - ui.add_space(5.0); - ui.label(RichText::new(error).color(DashColors::ERROR).size(12.0)); - } }); AppAction::None @@ -847,7 +843,7 @@ impl SingleKeyWalletSendScreen { action = send_action; } Err(e) => { - self.display_message(&e, MessageType::Error); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } } } @@ -855,10 +851,6 @@ impl SingleKeyWalletSendScreen { action } - - fn dismiss_message(&mut self) { - self.message = None; - } } impl ScreenLike for SingleKeyWalletSendScreen { @@ -880,38 +872,7 @@ impl ScreenLike for SingleKeyWalletSendScreen { let mut inner_action = AppAction::None; let dark_mode = ui.ctx().style().visuals.dark_mode; - // Display messages at the top - let mut should_dismiss = false; - if let Some((message, message_type, _)) = &self.message { - let message = message.clone(); - let message_color = match message_type { - MessageType::Error => DashColors::ERROR, - MessageType::Warning => DashColors::WARNING, - MessageType::Info => DashColors::text_primary(dark_mode), - MessageType::Success => Color32::DARK_GREEN, - }; - - ui.horizontal(|ui| { - Frame::new() - .fill(message_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, message_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label(RichText::new(&message).color(message_color)); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - should_dismiss = true; - } - }); - }); - }); - ui.add_space(10.0); - } - if should_dismiss { - self.dismiss_message(); - } + // Message display is handled by the global MessageBanner. egui::ScrollArea::vertical() .auto_shrink([true; 2]) @@ -969,6 +930,9 @@ impl ScreenLike for SingleKeyWalletSendScreen { } fn display_message(&mut self, message: &str, message_type: MessageType) { + // Error/success display is handled by the global MessageBanner. + // Only side-effects are preserved here. + // Check for success messages to reset sending state if message.contains("Sent") || message.contains("TxID") { self.sending = false; @@ -976,17 +940,14 @@ impl ScreenLike for SingleKeyWalletSendScreen { } // Check for min relay fee error and show confirmation dialog - if message_type == MessageType::Error + if matches!(message_type, MessageType::Error | MessageType::Warning) && let Some(required_fee) = Self::parse_min_relay_fee_error(message) { // Show the fee confirmation dialog instead of the error message self.fee_dialog.required_fee = required_fee; self.fee_dialog.is_open = true; // Keep sending state true until user confirms or cancels - return; } - - self.message = Some((message.to_string(), message_type, Utc::now())); } fn display_task_result( @@ -1023,7 +984,8 @@ impl ScreenLike for SingleKeyWalletSendScreen { txid ) }; - self.display_message(&msg, MessageType::Success); + MessageBanner::set_global(self.app_context.egui_ctx(), &msg, MessageType::Success); + self.fee_dialog.pending_request = None; // Clear the form after successful send self.recipients = vec![SendRecipient::new(0)]; diff --git a/src/ui/wallets/wallets_screen/address_table.rs b/src/ui/wallets/wallets_screen/address_table.rs index 92d35573e..57bd464fe 100644 --- a/src/ui/wallets/wallets_screen/address_table.rs +++ b/src/ui/wallets/wallets_screen/address_table.rs @@ -1,7 +1,8 @@ use crate::app::AppAction; use crate::model::wallet::{DerivationPathHelpers, DerivationPathReference}; +use crate::ui::MessageType; +use crate::ui::components::message_banner::MessageBanner; use crate::ui::wallets::account_summary::{AccountCategory, categorize_account_path}; -use crate::ui::{MessageType, ScreenLike}; use dash_sdk::dashcore_rpc::dashcore::{Address, Network}; use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; use dash_sdk::dpp::key_wallet::bip32::{ChildNumber, DerivationPath}; @@ -448,7 +449,13 @@ impl WalletsBalancesScreen { self.private_key_dialog.private_key_wif = key; self.private_key_dialog.show_key = false; } - Err(err) => self.display_message(&err, MessageType::Error), + Err(err) => { + MessageBanner::set_global( + self.app_context.egui_ctx(), + &err, + MessageType::Error, + ); + } } } } diff --git a/src/ui/wallets/wallets_screen/dialogs.rs b/src/ui/wallets/wallets_screen/dialogs.rs index acf0c2a07..f5c2d4dee 100644 --- a/src/ui/wallets/wallets_screen/dialogs.rs +++ b/src/ui/wallets/wallets_screen/dialogs.rs @@ -5,6 +5,7 @@ use crate::backend_task::wallet::WalletTask; use crate::model::amount::Amount; use crate::model::wallet::{DerivationPathHelpers, Wallet}; use crate::ui::MessageType; +use crate::ui::components::MessageBanner; use crate::ui::components::amount_input::AmountInput; use crate::ui::components::component_trait::{Component, ComponentResponse}; use crate::ui::helpers::copy_text_to_clipboard; @@ -1238,7 +1239,11 @@ impl WalletsBalancesScreen { pub(super) fn open_mine_dialog(&mut self) { let Some(wallet) = self.selected_wallet.clone() else { - self.set_message("Select a wallet first".to_string(), MessageType::Error); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Select a wallet first", + MessageType::Error, + ); return; }; diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index d6443b1ba..6ff955525 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -11,6 +11,7 @@ use crate::context::connection_status::spv_phase_summary; use crate::model::amount::Amount; use crate::model::wallet::{Wallet, WalletSeedHash, WalletTransaction}; use crate::spv::{CoreBackendMode, SpvStatus}; +use crate::ui::components::MessageBanner; use crate::ui::components::component_trait::Component; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::left_panel::add_left_panel; @@ -72,7 +73,6 @@ pub struct WalletsBalancesScreen { selected_wallet: Option>>, selected_single_key_wallet: Option>>, pub(crate) app_context: Arc, - message: Option<(String, MessageType, DateTime)>, sort_column: SortColumn, sort_order: SortOrder, refreshing: bool, @@ -82,7 +82,6 @@ pub struct WalletsBalancesScreen { show_sk_unlock_dialog: bool, sk_wallet_password: String, sk_show_password: bool, - sk_error_message: Option, remove_wallet_dialog: Option, pending_wallet_removal: Option, pending_wallet_removal_alias: Option, @@ -174,7 +173,6 @@ impl WalletsBalancesScreen { selected_wallet, selected_single_key_wallet, app_context: app_context.clone(), - message: None, sort_column: SortColumn::Index, sort_order: SortOrder::Ascending, refreshing: false, @@ -184,7 +182,6 @@ impl WalletsBalancesScreen { show_sk_unlock_dialog: false, sk_wallet_password: String::new(), sk_show_password: false, - sk_error_message: None, remove_wallet_dialog: None, pending_wallet_removal: None, pending_wallet_removal_alias: None, @@ -339,14 +336,22 @@ impl WalletsBalancesScreen { match result { Ok(address) => { let message = format!("Added new receiving address: {}", address); - self.display_message(&message, MessageType::Success); + MessageBanner::set_global( + self.app_context.egui_ctx(), + &message, + MessageType::Success, + ); } Err(e) => { - self.display_message(&e, MessageType::Error); + MessageBanner::set_global(self.app_context.egui_ctx(), &e, MessageType::Error); } } } else { - self.display_message("No wallet selected", MessageType::Error); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "No wallet selected", + MessageType::Error, + ); } } @@ -564,8 +569,9 @@ impl WalletsBalancesScreen { .db .remove_single_key_wallet(&key_hash, self.app_context.network) { - self.display_message( - &format!("Failed to remove: {}", e), + MessageBanner::set_global( + ui.ctx(), + format!("Failed to remove: {}", e), MessageType::Error, ); } else { @@ -576,7 +582,11 @@ impl WalletsBalancesScreen { self.selected_single_key_wallet = None; // Clear persisted selection in AppContext and database self.persist_selected_single_key_hash(None); - self.display_message("Wallet removed", MessageType::Success); + MessageBanner::set_global( + ui.ctx(), + "Wallet removed", + MessageType::Success, + ); } } @@ -725,14 +735,16 @@ impl WalletsBalancesScreen { self.wallet_unlock_popup.close(); self.refreshing = false; - self.display_message( - &format!("Removed wallet \"{}\" successfully", alias), + MessageBanner::set_global( + self.app_context.egui_ctx(), + format!("Removed wallet \"{}\" successfully", alias), MessageType::Success, ); } Err(err) => { - self.display_message( - &format!("Failed to remove wallet: {}", err), + MessageBanner::set_global( + self.app_context.egui_ctx(), + format!("Failed to remove wallet: {}", err), MessageType::Error, ); } @@ -803,18 +815,6 @@ impl WalletsBalancesScreen { }); } - fn dismiss_message(&mut self) { - self.message = None; - } - - fn check_message_expiration(&mut self) { - // Messages no longer auto-expire, they must be dismissed manually - } - - fn set_message(&mut self, message: String, message_type: MessageType) { - self.message = Some((message, message_type, Utc::now())); - } - fn format_dash(amount_duffs: u64) -> String { Amount::dash_from_duffs(amount_duffs).to_string() } @@ -930,7 +930,11 @@ impl WalletsBalancesScreen { .create_screen(&self.app_context), ); } else { - self.display_message("Select a wallet first", MessageType::Error); + MessageBanner::set_global( + ui.ctx(), + "Select a wallet first", + MessageType::Error, + ); } } @@ -1435,8 +1439,9 @@ impl WalletsBalancesScreen { let mut wallet = match wallet_arc.write() { Ok(guard) => guard, Err(err) => { - self.display_message( - &format!("Failed to lock wallet: {}", err), + MessageBanner::set_global( + self.app_context.egui_ctx(), + format!("Failed to lock wallet: {}", err), MessageType::Error, ); return; @@ -1453,7 +1458,11 @@ impl WalletsBalancesScreen { if locked { self.app_context.handle_wallet_locked(&wallet_arc); - self.display_message("Wallet locked", MessageType::Info); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Wallet locked", + MessageType::Info, + ); } } @@ -1507,8 +1516,6 @@ impl WalletsBalancesScreen { impl ScreenLike for WalletsBalancesScreen { fn ui(&mut self, ctx: &Context) -> AppAction { - self.check_message_expiration(); - // Check for pending platform balance refresh (triggered after transfers) let pending_refresh_action = if let Some(seed_hash) = self.pending_platform_balance_refresh.take() @@ -1569,38 +1576,7 @@ impl ScreenLike for WalletsBalancesScreen { let mut inner_action = AppAction::None; let dark_mode = ui.ctx().style().visuals.dark_mode; - // Display messages at the top, outside of scroll area - let message = self.message.clone(); - if let Some((message, message_type, _timestamp)) = message { - let message_color = match message_type { - MessageType::Error => DashColors::ERROR, - MessageType::Warning => DashColors::WARNING, - MessageType::Info => DashColors::text_primary(dark_mode), - MessageType::Success => egui::Color32::DARK_GREEN, - }; - - // Display message in a prominent frame with text wrapping - Frame::new() - .fill(message_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, message_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.add( - egui::Label::new( - egui::RichText::new(&message).color(message_color), - ) - .wrap(), - ); - ui.add_space(5.0); - if ui.small_button("Dismiss").clicked() { - self.dismiss_message(); - } - }); - }); - ui.add_space(10.0); - } + // Message display is handled by the global MessageBanner egui::ScrollArea::vertical() .auto_shrink([true; 2]) @@ -1740,7 +1716,7 @@ impl ScreenLike for WalletsBalancesScreen { self.private_key_dialog.show_key = false; } Err(err) => { - self.display_message(&err, MessageType::Error); + MessageBanner::set_global(ctx, &err, MessageType::Error); } } } @@ -1766,7 +1742,8 @@ impl ScreenLike for WalletsBalancesScreen { if self.pending_asset_lock_search_after_unlock { self.pending_asset_lock_search_after_unlock = false; if let Some(wallet_arc) = self.selected_wallet.clone() { - self.display_message( + MessageBanner::set_global( + ctx, "Searching for unused asset locks...", MessageType::Info, ); @@ -1859,45 +1836,23 @@ impl ScreenLike for WalletsBalancesScreen { match unlock_result { Ok(_) => { - self.sk_error_message = None; close_dialog = true; } Err(_) => { - self.sk_error_message = - Some("Incorrect Password".to_string()); + MessageBanner::set_global(ui.ctx(), "Incorrect Password", MessageType::Error); } } } self.sk_wallet_password.clear(); } - // Display error message if the password was incorrect - if let Some(error_message) = self.sk_error_message.clone() { - ui.add_space(5.0); - let error_color = DashColors::ERROR; - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label(RichText::new(format!("Error: {}", error_message)).color(error_color)); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.sk_error_message = None; - } - }); - }); - } + // Error display is handled by the global MessageBanner. }); }); if close_dialog { self.show_sk_unlock_dialog = false; self.sk_wallet_password.clear(); - self.sk_error_message = None; - // Check if we were trying to refresh the SK wallet if self.pending_refresh_after_unlock { self.pending_refresh_after_unlock = false; @@ -1961,7 +1916,11 @@ impl ScreenLike for WalletsBalancesScreen { action = AppAction::None; } else { // Wallet is unlocked - proceed with search - self.display_message("Searching for unused asset locks...", MessageType::Info); + MessageBanner::set_global( + ctx, + "Searching for unused asset locks...", + MessageType::Info, + ); action = AppAction::BackendTask(BackendTask::CoreTask( CoreTask::RecoverAssetLocks(wallet_arc), )); @@ -1975,7 +1934,8 @@ impl ScreenLike for WalletsBalancesScreen { } fn display_message(&mut self, message: &str, message_type: MessageType) { - if let MessageType::Error = message_type { + // Banner display is handled globally by AppState; this is only for side-effects. + if matches!(message_type, MessageType::Error | MessageType::Warning) { self.refreshing = false; // If the fund platform dialog is processing, show error in the dialog instead @@ -1983,10 +1943,8 @@ impl ScreenLike for WalletsBalancesScreen { self.fund_platform_dialog.is_processing = false; self.fund_platform_dialog.status = Some(message.to_string()); self.fund_platform_dialog.status_is_error = true; - return; } } - self.set_message(message.to_string(), message_type); } fn display_task_result( @@ -2006,13 +1964,15 @@ impl ScreenLike for WalletsBalancesScreen { self.refresh_platform_sync_info_cache(&hash); } if let Some(warn_msg) = warning { - self.set_message( + MessageBanner::set_global( + self.app_context.egui_ctx(), format!("Wallet refreshed with warning: {}", warn_msg), MessageType::Info, ); } else { - self.set_message( - "Successfully refreshed wallet".to_string(), + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Successfully refreshed wallet", MessageType::Success, ); } @@ -2030,7 +1990,7 @@ impl ScreenLike for WalletsBalancesScreen { Self::format_dash(total_amount) ) }; - self.display_message(&msg, MessageType::Success); + MessageBanner::set_global(self.app_context.egui_ctx(), &msg, MessageType::Success); } crate::ui::BackendTaskSuccessResult::WalletPayment { txid, @@ -2053,7 +2013,7 @@ impl ScreenLike for WalletsBalancesScreen { txid ) }; - self.display_message(&msg, MessageType::Success); + MessageBanner::set_global(self.app_context.egui_ctx(), &msg, MessageType::Success); } crate::ui::BackendTaskSuccessResult::GeneratedReceiveAddress { seed_hash, address } => { if let Some(selected) = &self.selected_wallet @@ -2079,16 +2039,25 @@ impl ScreenLike for WalletsBalancesScreen { } } crate::ui::BackendTaskSuccessResult::PlatformAddressWithdrawal { .. } => { - self.display_message("Platform withdrawal successful. Note: It may take a few minutes for funds to appear on the Core chain.", MessageType::Success); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Platform withdrawal successful. Note: It may take a few minutes for funds to appear on the Core chain.", + MessageType::Success, + ); } crate::ui::BackendTaskSuccessResult::PlatformAddressFunded { .. } => { self.fund_platform_dialog.is_processing = false; self.fund_platform_dialog.status = Some("Funding successful!".to_string()); self.fund_platform_dialog.status_is_error = false; - self.display_message("Platform address funded successfully", MessageType::Success); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Platform address funded successfully", + MessageType::Success, + ); } crate::ui::BackendTaskSuccessResult::PlatformCreditsTransferred { seed_hash } => { - self.display_message( + MessageBanner::set_global( + self.app_context.egui_ctx(), "Platform credits transferred successfully", MessageType::Success, ); @@ -2111,18 +2080,23 @@ impl ScreenLike for WalletsBalancesScreen { } } self.refresh_platform_sync_info_cache(&seed_hash); - self.set_message( - "Successfully synced Platform balances".to_string(), + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Successfully synced Platform balances", MessageType::Success, ); } crate::ui::BackendTaskSuccessResult::Message(msg) => { self.refreshing = false; - self.display_message(&msg, MessageType::Success); + MessageBanner::set_global(self.app_context.egui_ctx(), &msg, MessageType::Success); } crate::ui::BackendTaskSuccessResult::MineBlocksSuccess(count) => { self.refreshing = false; - self.display_message(&format!("Mined {} block(s)", count), MessageType::Success); + MessageBanner::set_global( + self.app_context.egui_ctx(), + format!("Mined {} block(s)", count), + MessageType::Success, + ); } _ => {} }