From f948c9b197feb4d4a2b582b8fe9c66b39e91854a Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 17 Feb 2026 16:32:19 +0100 Subject: [PATCH 01/46] docs: add unified message display design documents Add UX specification, technical architecture, and HTML mockup for the MessageBanner component that will replace the ~50 ad-hoc error/message display implementations across screens with a single reusable component. Key design decisions: - Per-screen MessageBanner with show()/set_message() API - All colors via DashColors (zero hardcoded Color32 values) - 4 severity levels: Error, Warning, Success, Info - Auto-dismiss for Success/Info (5s), persistent for Error/Warning - Follows Component Design Pattern conventions (private fields, builder, show) - No changes to BackendTask/TaskResult/AppState architecture Co-Authored-By: Claude Opus 4.6 --- .../architecture.md | 746 ++++++++++++++++++ .../message-banner-mockup.html | 445 +++++++++++ .../message-banner-ux-spec.md | 271 +++++++ 3 files changed, 1462 insertions(+) create mode 100644 docs/ai-design/2026-02-17-unified-messages/architecture.md create mode 100644 docs/ai-design/2026-02-17-unified-messages/message-banner-mockup.html create mode 100644 docs/ai-design/2026-02-17-unified-messages/message-banner-ux-spec.md diff --git a/docs/ai-design/2026-02-17-unified-messages/architecture.md b/docs/ai-design/2026-02-17-unified-messages/architecture.md new file mode 100644 index 000000000..c9afa675e --- /dev/null +++ b/docs/ai-design/2026-02-17-unified-messages/architecture.md @@ -0,0 +1,746 @@ +# MessageBanner Component -- Technical Architecture + +## 0. Current State Analysis + +### Overview + +The codebase has **~50 screens** (structs implementing `ScreenLike`) with no unified message rendering component. There are **4 distinct rendering patterns**, **8+ different error colors**, and **two competing `MessageType` enums**. Each screen independently manages its own message fields and rendering. + +### Rendering Style Taxonomy + +| Pattern | Description | Used By | +|---------|-------------|---------| +| **A - Framed Banner** | `Frame` with `fill(color.gamma_multiply(0.1))`, border stroke, RichText label, "Dismiss" button | ~8 screens | +| **B - Timed Badge** | `(String, MessageType, DateTime)` tuple, rendered with countdown timer (auto-dismiss ~10s) | ~8 screens | +| **C - Status Enum** | Error stored in a status enum variant (e.g., `TransferCreditsStatus::ErrorMessage`), rendered inline via `colored_label` | ~8 screens | +| **D - Bare colored_label** | Simple `ui.colored_label(Color32::X, message)` inline, no frame, no dismiss | ~20+ screens | + +### Error Color Inconsistency + +| Color Value | Usage Count | Screens | +|---|---|---| +| `Color32::from_rgb(255, 100, 100)` | ~8 screens | AddNewIdentityScreen, TopUpIdentityScreen, WalletsBalancesScreen, WalletSendScreen, PlatformInfoScreen, AddressBalanceScreen, KeyInfoScreen, ImportMnemonicScreen | +| `Color32::DARK_RED` | ~10 screens | WithdrawalScreen, TransferScreen, RegisterDpnsNameScreen, IdentitiesScreen, DPNSScreen, ContactsList, ContactRequests, AddContactScreen, SendPaymentScreen, SetTokenPriceScreen | +| `Color32::RED` | ~5 screens | FreezeTokensScreen, UnfreezeTokensScreen, PauseTokensScreen, ResumeTokensScreen, ClaimTokensScreen | +| `DashColors::error_color(dark_mode)` | ~7 screens | MintTokensScreen, BurnTokensScreen, DestroyFrozenFundsScreen, ProfileSearchScreen, ContactProfileViewerScreen, QRCodeGeneratorScreen, QRScannerScreen | +| `DashColors::ERROR` (`from_rgb(235,87,87)`) | ~1 screen | ContactInfoEditorScreen | +| `Color32::from_rgb(220, 80, 80)` | ~1 screen | WalletUnlockPopup | + +### Per-Screen Catalog + +#### Identities + +| Screen | File | Message Fields | Rendering | `display_message` | `display_task_result` | Quirks | +|---|---|---|---|---|---|---| +| `IdentitiesScreen` | `identities/identities_screen.rs` | `backend_message: Option<(String, MessageType, DateTime)>` | Pattern B (timed, 10s auto-dismiss with countdown) | Custom: sets `backend_message` | Custom: `RefreshedIdentity` → Success | Shows spinner during refresh; Error=`DARK_RED`, Success=`DARK_GREEN` | +| `AddNewIdentityScreen` | `identities/add_new_identity_screen/mod.rs` | `error_message: Option` | Pattern A (framed banner + Dismiss) | Custom: sets `error_message` | Default | Prefix: `"Error registering identity: {}"`. Color: `from_rgb(255,100,100)` | +| `AddExistingIdentityScreen` | `identities/add_existing_identity_screen.rs` | `error_message`, `backend_message`, `success_message` | Pattern A for errors | Custom: routes to 3 fields | Custom: `LoadedIdentity`, `Message` | **3 separate message fields**; `backend_message` = progress indicator | +| `TopUpIdentityScreen` | `identities/top_up_identity_screen/mod.rs` | `error_message: Option` | Pattern A (framed banner + Dismiss) | Custom: sets `error_message`; resets step state on Error | Default | Also resets `WalletFundedScreenStep` on error | +| `WithdrawalScreen` | `identities/withdraw_screen.rs` | `error_message`, `withdraw_from_identity_status` enum | Pattern C (status enum) | Custom: sets status enum | Custom: `WithdrewFromIdentity` → Complete | Status enum is primary error carrier | +| `TransferScreen` | `identities/transfer_screen.rs` | `error_message`, `transfer_credits_status` enum | Pattern C (status enum) | Custom: sets **both** enum AND `error_message` | Custom: `TransferredCredits` → Complete | **Redundant dual-field storage** | +| `RegisterDpnsNameScreen` | `identities/register_dpns_name_screen.rs` | `error_message: Option` | Pattern D (bare colored_label) | Custom: sets `error_message` | Default | Color: `DARK_RED` | + +#### Identity Keys + +| Screen | File | Message Fields | Rendering | `display_message` | `display_task_result` | Quirks | +|---|---|---|---|---|---|---| +| `KeysScreen` | `identities/keys/keys_screen.rs` | None | None | Default no-op | Default | Minimal screen, no messages | +| `KeyInfoScreen` | `identities/keys/key_info_screen.rs` | `error_message`, `sign_error_message` | Pattern A-like for `error_message`; Pattern D for `sign_error_message` | Default no-op | Default | **2 error fields** for different operations; no `display_message` override | +| `AddKeyScreen` | `identities/keys/add_key_screen.rs` | `error_message`, `add_key_status` enum | Pattern C (status enum) | Custom: sets `add_key_status` | Custom: `AddedKeyToIdentity` → Complete | | + +#### DPNS + +| Screen | File | Message Fields | Rendering | `display_message` | `display_task_result` | Quirks | +|---|---|---|---|---|---|---| +| `DPNSScreen` | `dpns/dpns_contested_names_screen.rs` | `message: Option<(String, MessageType, DateTime)>`, `bulk_schedule_message` | Pattern B (timed) + bulk block | Custom: sets `message` | Custom: extensive vote handling | **2 message slots**; bulk message uses emoji icons (❌/🎉) | + +#### Wallets + +| Screen | File | Message Fields | Rendering | `display_message` | `display_task_result` | Quirks | +|---|---|---|---|---|---|---| +| `WalletsBalancesScreen` | `wallets/wallets_screen/mod.rs` | `message: Option<(String, MessageType, DateTime)>`, `sk_error_message` | Pattern A (framed banner + Dismiss) | Custom: routes errors to fund dialog if processing | Custom: wallet ops | **Dialog interception**: fund-platform dialog captures errors | +| `AddNewWalletScreen` | `wallets/add_new_wallet_screen.rs` | `error: Option` | **Modal Window** (`egui::Window::new("Error")`) | Default no-op | Default | Only screen using modal error popup | +| `ImportMnemonicScreen` | `wallets/import_mnemonic_screen.rs` | `error: Option` | Pattern D (bare colored_label) | Default no-op | Default | No `display_message` override | +| `WalletSendScreen` | `wallets/send_screen.rs` | `send_status: SendStatus`, `error_message` | Pattern A (framed banner) + full success screen | Custom: sets `send_status` enum | Custom: `WalletPayment`, etc. | Status enum drives display; success shows full-page 🎉 view | +| `SingleKeyWalletSendScreen` | `wallets/single_key_send_screen.rs` | `message: Option<(String, MessageType, DateTime)>`, `error_message` | Pattern B (timed banner) | Custom: sets `message` | Custom: `WalletPayment` | 2 fields | +| `CreateAssetLockScreen` | `wallets/create_asset_lock_screen.rs` | `message: Option<(String, MessageType, DateTime)>`, `error_message` | Pattern B (timed banner) | Custom: sets `message` | Custom: asset lock types | Helper methods `set_error_message()`/`error_message()` | +| `AssetLockDetailScreen` | `wallets/asset_lock_detail_screen.rs` | `message: Option<(String, MessageType, DateTime)>`, `error_message` | Pattern B (timed banner) | Custom: sets `message` | Custom: asset lock retrieval | Helper methods | + +#### Contracts / Documents + +| Screen | File | Message Fields | Rendering | `display_message` | `display_task_result` | Quirks | +|---|---|---|---|---|---|---| +| `DocumentQueryScreen` | `contracts_documents/contracts_documents_screen.rs` | `error_message: Option<(String, MessageType, DateTime)>` | Pattern D (colored_label + timestamp expiry) | Custom: **only handles** `"Error fetching documents"` | Custom: Documents/PageDocuments | **Filters messages by text content** | +| `AddContractsScreen` | `contracts_documents/add_contracts_screen.rs` | `add_contracts_status` enum | Pattern C (status enum) | Custom: sets status enum | Custom: `FetchedContracts` | | +| `RegisterDataContractScreen` | `contracts_documents/register_contract_screen.rs` | `error_message`, `broadcast_status` enum | Pattern C + Pattern A | Custom: routes by text pattern | Custom: `RegisteredContract` | **Text-pattern branch** for proof error special case | +| `UpdateDataContractScreen` | `contracts_documents/update_contract_screen.rs` | `error_message`, `broadcast_status` enum | Pattern C + inline | Custom: same as Register | Custom: `UpdatedContract` | Same pattern as Register | +| `DocumentActionScreen` | `contracts_documents/document_action_screen.rs` | `backend_message: Option` | Not rendered via banner | Custom: sets `backend_message`; **ignores message_type** | Custom: Broadcasted/Deleted | `_message_type` — type parameter unused | +| `GroupActionsScreen` | `contracts_documents/group_actions_screen.rs` | `fetch_group_actions_status` enum | Pattern C (status enum) | Custom: sets status enum | Custom: `ActiveGroupActions` | | + +#### Tokens + +| Screen | File | Message Fields | Rendering | `display_message` | `display_task_result` | Quirks | +|---|---|---|---|---|---|---| +| `TokensScreen` | `tokens/tokens_screen/mod.rs` | `backend_message: Option<(String, MessageType, DateTime)>`, `token_creator_error_message` | Pattern B (timed banner) | Custom: smart routing (creator subpanel vs main) | Custom: extensive | **2 fields**; smart dispatch based on state | +| `MintTokensScreen` | `tokens/mint_tokens_screen.rs` | `error_message: Option` | Pattern D (colored_label) | Custom: Error → `error_message` | Default | Color: `DashColors::error_color(dark)` | +| `BurnTokensScreen` | `tokens/burn_tokens_screen.rs` | `error_message: Option` | Pattern D | Custom: Error → `error_message` | Default | Color: `DashColors::error_color(dark)` | +| `TransferTokensScreen` | `tokens/transfer_tokens_screen.rs` | `transfer_tokens_status` enum | Pattern C (status enum) | Custom: sets status enum | Custom: `TransferredTokens` | No `error_message` field | +| `FreezeTokensScreen` | `tokens/freeze_tokens_screen.rs` | `error_message: Option` | Pattern D | Custom: Error → `error_message` | Default | **Color: `Color32::RED`** (not DARK_RED) | +| `UnfreezeTokensScreen` | `tokens/unfreeze_tokens_screen.rs` | `error_message: Option` | Pattern D | Custom: Error → `error_message` | Default | Color: `Color32::RED` | +| `PauseTokensScreen` | `tokens/pause_tokens_screen.rs` | `error_message: Option` | Pattern D | Custom: Error → `error_message` | Default | Color: `Color32::RED` | +| `ResumeTokensScreen` | `tokens/resume_tokens_screen.rs` | `error_message: Option` | Pattern D | Custom: Error → `error_message` | Default | Color: `Color32::RED` | +| `DestroyFrozenFundsScreen` | `tokens/destroy_frozen_funds_screen.rs` | `error_message: Option` | Pattern D | Custom: Error → `error_message` | Default | Color: `DashColors::error_color(dark)` | +| `ClaimTokensScreen` | `tokens/claim_tokens_screen.rs` | `error_message: Option` | Pattern D | Custom: Error → `error_message` | Default | Color: `Color32::RED` | +| `ViewTokenClaimsScreen` | `tokens/view_token_claims_screen.rs` | `message: Option<(String, MessageType, DateTime)>` | Pattern D | Custom: all types → `message` | Custom: calls display_message if empty | | +| `AddTokenByIdScreen` | `tokens/add_token_by_id_screen.rs` | `error_message: Option` | Pattern D | Custom: Success = refresh; Error = `error_message` | (inline) | `display_message` handles Success as task result | +| `PurchaseTokenScreen` | `tokens/direct_token_purchase_screen.rs` | `error_message: Option` | Pattern D | Custom: Error → `error_message` | Default | | +| `SetTokenPriceScreen` | `tokens/set_token_price_screen.rs` | `error_message: Option` | Pattern D | Custom: Error → `error_message` | Default | Color: `DARK_RED`; also has amber validation labels | +| `UpdateTokenConfigScreen` | `tokens/update_token_config.rs` | `backend_message: Option<(String, MessageType, DateTime)>`, `error_message` (unused) | Pattern B (timed banner) | Custom: Error/Info → `backend_message` | Custom: `UpdatedTokenConfig` | **`error_message` field is explicitly commented `// unused`** | + +#### Tools + +| Screen | File | Message Fields | Rendering | `display_message` | `display_task_result` | Quirks | +|---|---|---|---|---|---|---| +| `PlatformInfoScreen` | `tools/platform_info_screen.rs` | `error_message: Option` | Pattern A (framed banner + Dismiss) | Custom: Error → `error_message` | Custom: PlatformInfo results | **Error blocks result display** (early return) | +| `GroveSTARKScreen` | `tools/grovestark_screen.rs` | `gen_error_message`, `verify_error_message` | Pattern D (with Dismiss in framed box) | Custom: routes to gen/verify based on mode | Custom: GeneratedProof, VerifiedProof | **2 mode-specific error fields** | +| `MasternodeListDiffScreen` | `tools/masternode_list_diff_screen.rs` | `ui_state.message: Option<(String, MessageType)>`, `ui_state.error` | Pattern A (framed banner) | Custom: Error → `ui_state.error`; Info → **silently discarded** | Custom: CoreItem, extensive | **Info messages explicitly dropped** | +| `ProofLogScreen` | `tools/proof_log_screen.rs` | None | None | Explicit no-op | Default | **Fully ignores all messages** | +| `ProofVisualizerScreen` | `tools/proof_visualizer_screen.rs` | None | None | Explicit no-op | Default | **Fully ignores all messages** | +| `TransitionVisualizerScreen` | `tools/transition_visualizer_screen.rs` | `broadcast_status` enum, `contract_fetch_message: Option<(String, Instant)>` | Pattern C (status enum) | Custom: routes by type | Custom: FetchedContract | `contract_fetch_message` is time-based | +| `AddressBalanceScreen` | `tools/address_balance_screen.rs` | `error_message: Option` | Pattern A (framed banner + Dismiss) | Custom: Error → `error_message` | Custom: AddressBalance | Color: `from_rgb(255,100,100)` | + +#### DashPay + +| Screen | File | Message Fields | Rendering | `display_message` | `display_task_result` | Quirks | +|---|---|---|---|---|---|---| +| `DashPayScreen` | `dashpay/dashpay_screen.rs` | None (delegates) | Delegates to active sub-screen | Custom: dispatches to sub-screens | Default | Pure delegator | +| `ContactsList` | `dashpay/contacts_list.rs` | `message: Option<(String, MessageType)>` | Pattern D | Custom: sets `message` | Custom | Color: `DARK_RED`/`DARK_GREEN`/`LIGHT_BLUE` | +| `ContactRequests` | `dashpay/contact_requests.rs` | `message: Option<(String, MessageType)>` | Pattern D (bold for errors) | Custom: sets `message` | Custom | | +| `ContactDetailsScreen` | `dashpay/contact_details.rs` | `message: Option<(String, MessageType)>` | Pattern D | **Public + trait impl** (trait delegates to pub fn) | Custom | | +| `ContactInfoEditorScreen` | `dashpay/contact_info_editor.rs` | `message: Option<(String, MessageType)>` | Pattern D | **Public + trait impl** | Custom | Uses `DashColors::SUCCESS/ERROR/INFO` constants | +| `ProfileSearchScreen` | `dashpay/profile_search.rs` | `message: Option<(String, MessageType)>` | Pattern D | **Public + trait impl** | Custom | Color: `DashColors::error_color(dark)` | +| `ContactProfileViewerScreen` | `dashpay/contact_profile_viewer.rs` | `message: Option<(String, MessageType)>` | Pattern D | **Public + trait impl** | Custom | | +| `AddContactScreen` | `dashpay/add_contact_screen.rs` | `message: Option<(String, MessageType)>` | Pattern D | Custom: wraps `AddContactError` | Custom | | +| `QRCodeGeneratorScreen` | `dashpay/qr_code_generator.rs` | `message: Option<(String, MessageType)>` | Pattern D | **Public + trait impl** | Default | | +| `QRScannerScreen` | `dashpay/qr_scanner.rs` | `message: Option<(String, MessageType)>` | Pattern D | **Public + trait impl** | Custom | | +| `SendPaymentScreen` | `dashpay/send_payment.rs` | `message: Option<(String, MessageType)>` | Pattern D | Custom: sets `sending=false` + `message` | Custom: `DashPayPaymentSent` | | + +#### Network / Other + +| Screen | File | Message Fields | Rendering | `display_message` | `display_task_result` | Quirks | +|---|---|---|---|---|---|---| +| `NetworkChooserScreen` | `network_chooser_screen.rs` | `custom_dash_qt_error_message`, `spv_clear_message`, `db_clear_message` | Inline specialized fields | Default no-op | Default | **3 specialized local message fields**; no standard message pipeline | + +### Key Issues Summary + +1. **No unified rendering component** — 4 distinct visual patterns with no shared helper +2. **8+ different error colors** — ranges from `Color32::RED` to `Color32::DARK_RED` to `from_rgb(255,100,100)` to `DashColors::error_color(dark)` +3. **Screens silently ignoring messages** — `KeysScreen`, `ProofLogScreen`, `ProofVisualizerScreen` (no-op), `MasternodeListDiffScreen` (drops Info), `DocumentActionScreen` (ignores type) +4. **Redundant dual-field storage** — `TransferScreen` stores errors in both status enum and `error_message` +5. **Dead code** — `UpdateTokenConfigScreen.error_message` explicitly marked unused; `theme.rs::MessageType` is dead code +6. **6 screens with public+trait dual display_message** — DashPay sub-screens have both a `pub fn` and `ScreenLike` impl +7. **Inconsistent auto-dismiss** — 8 screens use `DateTime` timestamps for timed messages; the rest persist until user action +8. **Success messages often silently lost** — Token action screens (Mint, Burn, Freeze, etc.) use default `display_task_result` which calls `display_message("Success", Success)`, but their `display_message` only handles Error + +--- + +## 1. MessageType Enum Unification + +### Current State + +Two competing `MessageType` enums exist: + +1. **`src/ui/mod.rs:830`** -- Active, 3 variants (Success, Info, Error), used by `ScreenLike` trait +2. **`src/ui/theme.rs:638`** -- Dead code (`#[allow(dead_code)]`), 4 variants (Success, Error, Warning, Info), has `color()` and `background_color()` methods + +### Decision + +**Extend** the active enum in `mod.rs` by adding a `Warning` variant. **Consolidate** the color methods from the dead-code `theme.rs::MessageType` into `DashColors` as dark-mode-aware methods (the dead enum only had light-mode colors). Then **delete** the dead-code enum in `theme.rs` (lines 636-664). + +### Unified Enum (in `src/ui/mod.rs`) + +```rust +#[derive(Debug, PartialEq, Clone, Copy)] +pub enum MessageType { + Success, + Info, + Warning, + Error, +} +``` + +This is the only change to the existing `MessageType`. The variant order follows severity (lowest to highest). Adding `Warning` is backward-compatible -- existing match arms that use `Success`, `Info`, `Error` will get a compiler error only in exhaustive matches, which is desirable to ensure all sites handle the new variant. + +### New DashColors Methods (in `src/ui/theme.rs`) + +Add `info_color(dark_mode)` and `message_background_color(message_type, dark_mode)` to `DashColors`, consolidating all message-type-aware colors in one place: + +```rust +impl DashColors { + /// Info severity color -- complements existing error_color/success_color/warning_color. + pub fn info_color(dark_mode: bool) -> Color32 { + if dark_mode { + Color32::from_rgb(100, 180, 255) // Lighter blue for dark mode + } else { + Self::DEEP_BLUE // Dark blue for light mode + } + } + + /// Returns the tinted background color for a message severity level. + /// Uses low alpha (8% light, 12% dark) for subtle tinting. + pub fn message_background_color(message_type: MessageType, dark_mode: bool) -> Color32 { + let alpha = if dark_mode { 30 } else { 20 }; + match message_type { + MessageType::Error => { + let c = if dark_mode { (255, 100, 100) } else { (235, 87, 87) }; + Color32::from_rgba_unmultiplied(c.0, c.1, c.2, alpha) + } + MessageType::Warning => { + let c = if dark_mode { (255, 200, 100) } else { (241, 196, 15) }; + Color32::from_rgba_unmultiplied(c.0, c.1, c.2, alpha) + } + MessageType::Success => { + let c = if dark_mode { (80, 200, 120) } else { (39, 174, 96) }; + Color32::from_rgba_unmultiplied(c.0, c.1, c.2, alpha) + } + MessageType::Info => { + let c = if dark_mode { (100, 180, 255) } else { (52, 152, 219) }; + Color32::from_rgba_unmultiplied(c.0, c.1, c.2, alpha) + } + } + } + + /// Returns the foreground (text/border) color for a message severity level. + /// Delegates to existing per-severity color methods. + pub fn message_color(message_type: MessageType, dark_mode: bool) -> Color32 { + match message_type { + MessageType::Error => Self::error_color(dark_mode), + MessageType::Warning => Self::warning_color(dark_mode), + MessageType::Success => Self::success_color(dark_mode), + MessageType::Info => Self::info_color(dark_mode), + } + } +} +``` + +This approach: +- Keeps all color definitions in `DashColors` (single source of truth for the design system) +- Reuses existing `error_color`/`success_color`/`warning_color` methods for foreground colors +- Fills the gap: `info_color(dark_mode)` was missing +- Replaces the dead `theme.rs::MessageType` color methods with dark-mode-aware equivalents + +--- + +## 2. MessageBanner Component + +### Alignment with Component Design Pattern + +`MessageBanner` follows the conventions established in `doc/COMPONENT_DESIGN_PATTERN.md`: + +| Convention | How MessageBanner Applies It | +|---|---| +| **Private fields only** | `state: Option` is private | +| **`new()` constructor** | `MessageBanner::new()` creates an empty banner | +| **Builder methods** | `with_auto_dismiss_duration()` for configurable timeout | +| **`show()` method** | `show(&mut self, ui: &mut Ui)` renders the banner | +| **Self-contained** | Handles rendering, dismiss, auto-dismiss, and color resolution internally | +| **Colors via design system** | Uses `DashColors::message_color()`, `DashColors::message_background_color()`, and `DashColors::text_secondary()` — no hardcoded colors | + +**Why MessageBanner does NOT implement the `Component` trait:** + +The `Component` trait (in `component_trait.rs`) is designed for **input** components that produce domain values — `AmountInput` produces `Amount`, `ConfirmationDialog` produces `ConfirmationStatus`. Each has `DomainType`, `ComponentResponse` with `has_changed()`/`changed_value()`/`update()`, and `current_value()`. + +`MessageBanner` is a **display** component that **consumes** data (receives messages) rather than **producing** domain values. It has no meaningful `DomainType` to bind, no user-produced value to return, and no `update()` target. Forcing the `Component` trait would require artificial type parameters (`DomainType = ()`) that add complexity without value. + +This is consistent with how other display-only widgets in the codebase work — e.g., `StyledButton`, `GradientHeading`, `InfoPopup` all have `show()` without implementing `Component`. + +### File Location + +`src/ui/components/message_banner.rs` + +Register in `src/ui/components/mod.rs`: +```rust +pub mod message_banner; +pub use message_banner::MessageBanner; +``` + +### Struct Definition + +```rust +use crate::ui::MessageType; +use crate::ui::theme::DashColors; +use std::time::{Duration, Instant}; + +const DEFAULT_AUTO_DISMISS_DURATION: Duration = Duration::from_secs(5); + +/// A self-contained banner widget for displaying screen-level messages. +/// +/// Each screen owns one instance. Call `show()` every frame inside the +/// screen's `ui()` method, before the `ScrollArea`. The banner renders +/// nothing when no message is set. +/// +/// Follows the component conventions from `doc/COMPONENT_DESIGN_PATTERN.md`: +/// private fields, `new()` constructor, builder methods, `show()` rendering. +pub struct MessageBanner { + /// Current message state: text, severity, and the instant it was set. + /// `None` means no message is displayed. + state: Option, + /// Duration before Success/Info messages auto-dismiss. + auto_dismiss_duration: Duration, +} + +struct MessageState { + text: String, + message_type: MessageType, + created_at: Instant, +} +``` + +The struct is intentionally minimal. `state` is `Option` so the banner occupies zero layout space when empty. `Instant` is used for auto-dismiss timing because it is monotonic and does not depend on wall clock. + +### Public API + +```rust +impl MessageBanner { + /// Creates an empty banner (no message displayed). + pub fn new() -> Self { + Self { + state: None, + auto_dismiss_duration: DEFAULT_AUTO_DISMISS_DURATION, + } + } + + /// Builder: set custom auto-dismiss duration for Success/Info messages. + pub fn with_auto_dismiss_duration(mut self, duration: Duration) -> Self { + self.auto_dismiss_duration = duration; + self + } + + /// 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) { + if text.is_empty() { + self.state = None; + return; + } + self.state = Some(MessageState { + text: text.to_string(), + message_type, + created_at: Instant::now(), + }); + } + + /// Clears the current message immediately. + pub fn clear(&mut self) { + self.state = None; + } + + /// Returns whether a message is currently displayed. + pub fn has_message(&self) -> bool { + self.state.is_some() + } + + /// Renders the banner into the given `Ui`. + /// Call this every frame before the ScrollArea. + pub fn show(&mut self, ui: &mut egui::Ui) { + // Implementation below + } +} + +impl Default for MessageBanner { + fn default() -> Self { + Self::new() + } +} +``` + +### Rendering Implementation (`show`) + +All colors are resolved through `DashColors` methods — no hardcoded `Color32` values in the component. + +```rust +pub fn show(&mut self, ui: &mut egui::Ui) { + // 1. Check if there is a message to display + let Some(state) = &self.state else { return }; + + // 2. Auto-dismiss check for Success and Info + let auto_dismiss = matches!(state.message_type, MessageType::Success | MessageType::Info); + if auto_dismiss && state.created_at.elapsed() >= self.auto_dismiss_duration { + self.state = None; + return; + } + + // 3. Resolve colors via DashColors (single source of truth) + let dark_mode = ui.ctx().style().visuals.dark_mode; + let fg_color = DashColors::message_color(state.message_type, dark_mode); + let bg_color = DashColors::message_background_color(state.message_type, dark_mode); + let secondary_color = DashColors::text_secondary(dark_mode); + + // 4. Compute remaining seconds for countdown (only for auto-dismiss types) + let remaining_secs = if auto_dismiss { + let elapsed = state.created_at.elapsed(); + self.auto_dismiss_duration + .checked_sub(elapsed) + .map(|d| d.as_secs() + 1) // +1 so we show "1s" until it actually expires + } else { + None + }; + + // 5. Render using DashColors and theme constants + let icon = icon_for_type(state.message_type); + let text = state.text.clone(); // clone to release borrow on self + let mut dismissed = false; + + egui::Frame::new() + .fill(bg_color) + .inner_margin(egui::Margin::symmetric( + Spacing::SM_I8 + Spacing::XXS as i8, // 10px + Spacing::SM_I8, // 8px + )) + .corner_radius(Shape::RADIUS_SM as f32) // 6px + .stroke(egui::Stroke::new(Shape::BORDER_WIDTH, fg_color)) + .show(ui, |ui| { + ui.horizontal(|ui| { + // Icon + ui.label(egui::RichText::new(icon).color(fg_color).strong()); + ui.add_space(Spacing::XS); // 4px + + // Message text (wrapping allowed via Label) + ui.label(egui::RichText::new(&text).color(fg_color)); + + // Flexible space + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // Dismiss button (rightmost) + if ui.small_button("x").clicked() { + dismissed = true; + } + + // Countdown (before dismiss button in RTL layout) + if let Some(secs) = remaining_secs { + ui.label( + egui::RichText::new(format!("({}s)", secs)) + .font(Typography::body_small()) + .color(secondary_color), + ); + } + }); + }); + }); + ui.add_space(Spacing::SM as f32); // 8px below banner + + if dismissed { + self.state = None; + } +} +``` + +### Color Resolution — All Through DashColors + +The banner uses **zero hardcoded colors**. All color resolution goes through `DashColors`: + +| Purpose | Method | Source | +|---|---|---| +| Text & border color | `DashColors::message_color(type, dark_mode)` | Delegates to `error_color()` / `success_color()` / `warning_color()` / `info_color()` | +| Background tint | `DashColors::message_background_color(type, dark_mode)` | Semantic backgrounds at 8% (light) / 12% (dark) alpha | +| Countdown text | `DashColors::text_secondary(dark_mode)` | Standard secondary text color | +| Spacing values | `Spacing::XS`, `Spacing::SM`, etc. | Theme constants | +| Corner radius | `Shape::RADIUS_SM` (6px) | Theme constants | +| Border width | `Shape::BORDER_WIDTH` (1px) | Theme constants | + +This ensures that if the design system colors are updated globally, the banner automatically reflects the changes. + +### Icon Selection + +```rust +fn icon_for_type(message_type: MessageType) -> &'static str { + match message_type { + MessageType::Error => "\u{26A0}", // ⚠ warning sign (visually distinct via color) + MessageType::Warning => "\u{26A0}", // ⚠ same glyph, differentiated by color + MessageType::Success => "\u{2713}", // ✓ check mark + MessageType::Info => "\u{2139}", // ℹ info + } +} +``` + +Note: If the egui font does not render these Unicode characters, fall back to ASCII: `"!"`, `"!"`, `"v"`, `"i"`. The implementer should verify glyph availability at development time. + +--- + +## 3. ScreenLike Trait Changes + +### Current Signature (unchanged) + +```rust +pub trait ScreenLike { + fn refresh(&mut self) {} + fn refresh_on_arrival(&mut self) { self.refresh() } + fn ui(&mut self, ctx: &Context) -> AppAction; + 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) + } + fn pop_on_success(&mut self) {} +} +``` + +**No signature change is needed.** The `display_message` method signature already accepts `&str` and `MessageType`. Adding `Warning` to the `MessageType` enum is the only change, and it flows through the existing signature. + +### Default Implementation Consideration + +It would be tempting to add a default `display_message` implementation that delegates to a `banner` field. However, `ScreenLike` is a trait and has no access to struct fields, so no default implementation is possible without an additional accessor method. + +**Option considered and rejected:** Adding a `fn banner(&mut self) -> &mut MessageBanner` method to ScreenLike. This would save boilerplate but forces every screen to have a field named in a specific way, and changes the trait contract for all 45+ implementors simultaneously. The cost of the two-line `display_message` implementation per screen is low. + +**Recommendation:** Each screen implements `display_message` as a two-line method: + +```rust +fn display_message(&mut self, message: &str, message_type: MessageType) { + self.banner.set_message(message, message_type); +} +``` + +Some screens perform additional logic in `display_message` (e.g., `TopUpIdentityScreen` resets its step state on error). Those screens keep their custom logic and add the `banner.set_message()` call. + +--- + +## 4. Migration Plan + +### Phase 1: Create Component + Unify MessageType + Extend DashColors + +**Scope:** 3 files changed, 1 file created + +1. **Update `src/ui/mod.rs`** -- Add `Warning` variant to `MessageType` enum (line 830). +2. **Update `src/ui/theme.rs`** -- Add `info_color(dark_mode)`, `message_color(message_type, dark_mode)`, and `message_background_color(message_type, dark_mode)` to `DashColors`. Delete the dead-code `MessageType` enum and its `impl` block (lines 636-664). +3. **Create `src/ui/components/message_banner.rs`** -- Full `MessageBanner` implementation as described in Section 2, using only `DashColors` for colors and theme constants for spacing/shape. +4. **Update `src/ui/components/mod.rs`** -- Add `pub mod message_banner; pub use message_banner::MessageBanner;` +5. **Fix compile errors** -- Any exhaustive `match` on `MessageType` will need a `Warning` arm. These are likely limited to `app.rs` (the `TaskResult` routing) and any screen that matches on message type in `display_message`. Add `MessageType::Warning => { /* same as Error for now */ }` to each. + +**Validation:** `cargo build` succeeds. No behavioral change yet. + +### Phase 2: Migrate Screens Incrementally + +Each screen migration follows the same mechanical pattern. Screens can be migrated one at a time in separate commits, enabling incremental review. + +**Per-screen changes:** + +1. Add `use crate::ui::components::MessageBanner;` (if not already via glob import) +2. Replace `error_message: Option` (and similar fields) with `banner: MessageBanner` +3. In `new()` / constructor: replace `error_message: None` with `banner: MessageBanner::new()` +4. In `ui()`: replace the inline `Frame` error rendering block with `self.banner.show(ui);` +5. In `display_message()`: replace `self.error_message = Some(...)` with `self.banner.set_message(message, message_type);` +6. Remove any manual dismiss logic (the banner handles it) +7. If the screen sets `self.error_message = Some(...)` inline during `ui()`, replace with `self.banner.set_message(...)` +8. If the screen clears the error (e.g., `self.error_message = None`), replace with `self.banner.clear()` + +**Estimated effort per screen:** ~5-15 lines changed per screen file. Mechanical, low risk. + +**Migration order suggestion:** +1. Start with a simple screen (e.g., `transfer_screen.rs`) as a proof-of-concept +2. Then migrate screens with the exact duplicate pattern (the `Frame` + dismiss button block identical to `top_up_identity_screen`) +3. Then migrate screens with custom `display_message` logic +4. Finally migrate screens that use status enums with `ErrorMessage(String)` variants + +### Phase 3: Remove Dead Code + +1. Delete the `#[allow(dead_code)]` annotations from `theme.rs` if the dead `MessageType` was removed in Phase 1 +2. Remove any helper functions that were only used for the old error rendering pattern (if any exist) +3. Run `cargo clippy` to find any remaining unused code related to the old pattern + +--- + +## 5. What Stays Unchanged + +| Concern | Status | Rationale | +|---------|--------|-----------| +| **Status enums** (Pattern 2, e.g., `TransferCreditsStatus::ErrorMessage(String)`) | Keep as-is | These track screen workflow state. The screen can use both a status enum for flow control and `MessageBanner` for display. When entering the `ErrorMessage` state, the screen calls `self.banner.set_message(...)`. | +| **`show_success_screen()` helper** (Pattern 6, `src/ui/helpers.rs`) | Keep as-is | This renders a full-page success view with detailed info, not a banner. Different purpose entirely. | +| **Inline validation** (Pattern 8, e.g., `AmountInput` error messages) | Keep as-is | These are field-level validation hints rendered next to the input. They are not screen-level messages. | +| **`ConfirmationDialog`** | Keep as-is | Modal dialog for confirming actions. Orthogonal to message banners. | +| **BackendTask / TaskResult / AppState** | Keep as-is | The task routing in `app.rs` calls `display_message()` on the visible screen. This works identically with `MessageBanner` -- the screen's `display_message` impl just delegates to `banner.set_message()`. | +| **`AppAction` enum** | Keep as-is | No new action variants needed. | +| **`BackendTaskSuccessResult`** | Keep as-is | No changes to result types. | +| **`component_trait.rs` (`Component` / `ComponentResponse`)** | Not implemented by `MessageBanner` | `Component` trait is for input widgets that produce domain values (`AmountInput` → `Amount`). `MessageBanner` is a display widget that consumes data. It follows the pattern's conventions (private fields, builder, `show()`, DashColors) without the trait. Same approach as `StyledButton`, `GradientHeading`, `InfoPopup`. | + +--- + +## 6. Code Examples: Before/After Migration + +### Example: `TopUpIdentityScreen` + +#### Before (current code) + +**Struct definition** (`src/ui/identities/top_up_identity_screen/mod.rs:44-66`): +```rust +pub struct TopUpIdentityScreen { + pub identity: QualifiedIdentity, + // ... other fields ... + error_message: Option, + // ... +} +``` + +**Constructor:** +```rust +impl TopUpIdentityScreen { + pub fn new(qualified_identity: QualifiedIdentity, app_context: &Arc) -> Self { + Self { + // ... + error_message: None, + // ... + } + } +} +``` + +**Error rendering in `ui()`** (lines 531-552): +```rust +// Display error message at the top, outside of scroll area +if let Some(error_message) = self.error_message.clone() { + let message_color = egui::Color32::from_rgb(255, 100, 100); + + ui.horizontal(|ui| { + egui::Frame::new() + .fill(message_color.gamma_multiply(0.1)) + .inner_margin(egui::Margin::symmetric(10, 8)) + .corner_radius(5.0) + .stroke(egui::Stroke::new(1.0, message_color)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(egui::RichText::new(&error_message).color(message_color)); + ui.add_space(10.0); + if ui.small_button("Dismiss").clicked() { + self.error_message = None; + } + }); + }); + }); + ui.add_space(10.0); +} +``` + +**`display_message` impl** (lines 430-435): +```rust +fn display_message(&mut self, message: &str, message_type: MessageType) { + if message_type == MessageType::Error { + self.error_message = Some(format!("Error topping up identity: {}", message)); + // Reset step so UI is not stuck on waiting messages + let mut step = self.step.write().unwrap(); + if *step == WalletFundedScreenStep::WaitingForPlatformAcceptance + || *step == WalletFundedScreenStep::WaitingForAssetLock + { + *step = WalletFundedScreenStep::ChooseFundingMethod; + } + } +} +``` + +#### After (migrated) + +**Struct definition:** +```rust +use crate::ui::components::MessageBanner; + +pub struct TopUpIdentityScreen { + pub identity: QualifiedIdentity, + // ... other fields ... + banner: MessageBanner, // replaces error_message: Option + // ... +} +``` + +**Constructor:** +```rust +Self { + // ... + banner: MessageBanner::new(), // replaces error_message: None + // ... +} +``` + +**Rendering in `ui()`** (replaces the 20-line block): +```rust +// Display message banner at the top, outside of scroll area +self.banner.show(ui); +``` + +**`display_message` impl** (keeps custom step-reset logic): +```rust +fn display_message(&mut self, message: &str, message_type: MessageType) { + self.banner.set_message(message, message_type); + if message_type == MessageType::Error { + // Reset step so UI is not stuck on waiting messages + let mut step = self.step.write().unwrap(); + if *step == WalletFundedScreenStep::WaitingForPlatformAcceptance + || *step == WalletFundedScreenStep::WaitingForAssetLock + { + *step = WalletFundedScreenStep::ChooseFundingMethod; + } + } +} +``` + +Note that the error message prefix `"Error topping up identity: "` is dropped. The banner's visual styling (red color, error icon) already communicates that it is an error. The backend task error string itself is descriptive enough. + +### Example: Simple Screen (no custom `display_message`) + +Many screens have no custom `display_message` override and rely on the default no-op. After migration, these screens get a one-line `display_message`: + +```rust +fn display_message(&mut self, message: &str, message_type: MessageType) { + self.banner.set_message(message, message_type); +} +``` + +And a one-line render call in `ui()`: + +```rust +self.banner.show(ui); +``` + +--- + +## 7. Architectural Decisions Summary + +| Decision | Rationale | +|----------|-----------| +| Per-screen ownership (not global) | Matches egui immediate-mode model. No shared mutable state. Each screen is self-contained. | +| `Instant` for timing (not egui frame time) | Monotonic, works correctly even if frames are missed while screen is not visible. | +| Follows Component Design Pattern conventions, not `Component` trait | `MessageBanner` is a display widget (consumes data), not an input widget (produces domain values). It follows the pattern's conventions (private fields, builder, `show()`, DashColors) like `StyledButton`/`InfoPopup`. | +| All colors via `DashColors` methods | Zero hardcoded `Color32` values. New `DashColors::message_color()`, `message_background_color()`, and `info_color()` methods ensure single source of truth. If the design system is updated, the banner reflects changes automatically. | +| Delete dead `theme.rs::MessageType`, consolidate into `DashColors` | The dead enum had light-mode-only `color()`/`background_color()` methods. The replacement `DashColors::message_color(type, dark_mode)` and `DashColors::message_background_color(type, dark_mode)` are dark-mode-aware and reusable by any component. | +| No accessor method on `ScreenLike` | Avoids coupling the trait to a specific field name. The two-line `display_message` boilerplate is acceptable. | +| Incremental migration (one screen at a time) | Reduces risk. Each migration is a small, reviewable change. Old and new patterns coexist during migration. | + +--- + +## 8. Alternatives Considered + +### Alternative A: Global App-Level Banner (Rejected) + +Render the banner in `AppState::update()` above all screens, eliminating per-screen ownership. + +**Pros:** Zero migration effort for backend task errors — `AppState` would call `banner.set_message()` directly instead of routing through `display_message()`. + +**Rejected because:** Screens render inside panels with different layouts (left sidebar, main content, modals). A global banner can't know the correct placement within each screen's layout. Also, screens that set validation errors during `ui()` would still need their own banner. Result: two systems instead of one. + +### Alternative B: Implement `Component` Trait (Rejected) + +Make `MessageBanner` implement `Component`. + +**Pros:** Perfectly uniform with `AmountInput` and `ConfirmationDialog`. + +**Rejected because:** `Component` requires `DomainType` (the value the component produces), `ComponentResponse` with `has_changed()`/`changed_value()`/`update()`, and `current_value()`. A display widget has no meaningful domain value to produce. Using `DomainType = ()` and `changed_value() -> &Option<()>` would be semantically empty. The pattern doc itself describes components that "handle wallet selection" or "manage passwords" — input components. Display-only widgets (`StyledButton`, `GradientHeading`, `InfoPopup`) don't use the trait either. + +### Alternative C: Add `fn banner() -> &mut MessageBanner` to ScreenLike (Rejected) + +Add an accessor method to the trait so `AppState` could call `screen.banner().set_message()` directly. + +**Pros:** Eliminates the two-line `display_message` boilerplate per screen. + +**Rejected because:** Forces every `ScreenLike` implementor to have a `MessageBanner` field (including screens that legitimately don't need messages, like `ProofLogScreen`). Changing the trait contract for 50+ screens simultaneously increases migration risk. + +### Alternative D: Use `DateTime` for Timing (Rejected) + +8 screens already use `DateTime` for timed messages. + +**Rejected because:** `Instant` is monotonic (immune to system clock changes), simpler (no chrono dependency needed), and purpose-built for duration measurement. `DateTime` is correct for display timestamps but wrong for timeout logic. diff --git a/docs/ai-design/2026-02-17-unified-messages/message-banner-mockup.html b/docs/ai-design/2026-02-17-unified-messages/message-banner-mockup.html new file mode 100644 index 000000000..baccfa7c0 --- /dev/null +++ b/docs/ai-design/2026-02-17-unified-messages/message-banner-mockup.html @@ -0,0 +1,445 @@ + + + + + +MessageBanner Component Mockup - Dash Evo Tool + + + + +
+ +
+ Dash Evo Tool +
+ +
+
+ +
+ + + + +
+ + + + +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+ Mockup reference for MessageBanner component. Spec: message-banner-ux-spec.md | Corner radius: 6px | Padding: 10px h, 8px v | Border: 1px | Gap between icon and text: 4px +
+
+
+
+ + + + + diff --git a/docs/ai-design/2026-02-17-unified-messages/message-banner-ux-spec.md b/docs/ai-design/2026-02-17-unified-messages/message-banner-ux-spec.md new file mode 100644 index 000000000..45123dd03 --- /dev/null +++ b/docs/ai-design/2026-02-17-unified-messages/message-banner-ux-spec.md @@ -0,0 +1,271 @@ +# MessageBanner Component -- UX Specification + +## 1. Overview + +The `MessageBanner` is a self-contained egui component that replaces all ad-hoc `error_message: Option` fields across screens. Each screen owns one `MessageBanner` instance (not global). It displays a single message at a time with consistent styling based on severity. + +--- + +## 2. Severity Levels + +| Severity | Persistence | Dismiss | Use Case | +|----------|---------------------|--------------------|-----------------------------------------------| +| Error | Persistent | Manual only | Task failures, validation errors | +| Warning | Persistent | Manual only | Risky actions, degraded state | +| Success | Auto-dismiss ~5s | Manual or auto | Completed operations | +| Info | Auto-dismiss ~5s | Manual or auto | Informational feedback, neutral status updates | + +All four types display a dismiss button. Success and Info also count down and auto-clear. + +--- + +## 3. Visual Design + +### 3.1 Layout Structure + +``` ++-----------------------------------------------------------------------+ +| [Icon] Message text here [5s] [Dismiss] | ++-----------------------------------------------------------------------+ +``` + +The banner is a single horizontal row inside an `egui::Frame` with: +- **Corner radius**: 6px (`Shape::RADIUS_SM`) +- **Inner margin**: 10px horizontal, 8px vertical (matching existing pattern in `top_up_identity_screen`) +- **Outer spacing**: 10px below the banner (`ui.add_space(10.0)`) +- **Full available width**: The frame should expand to `ui.available_width()` + +### 3.2 Content Arrangement (left to right) + +1. **Icon character** (Unicode text, not image): Provides at-a-glance severity recognition. + - Error: `!` (exclamation in circle, or literal `!` if unicode unavailable) + - Warning: `!` (triangle-style -- visually distinct from error via color) + - Success: Checkmark character + - Info: `i` (info style) + - Font size: `Typography::SCALE_BASE` (16px), bold + - Color: Same as the text color for that severity + +2. **4px gap** (`Spacing::XS`) + +3. **Message text**: Left-aligned, wrapping permitted for long messages. + - Font: `Typography::body()` (16px proportional) + - Color: Severity-specific foreground color (see Section 3.3) + +4. **Flexible space** (push remaining elements to the right) + +5. **Countdown label** (Success and Info only): Shows remaining seconds, e.g., `(3s)`. + - Font: `Typography::body_small()` (14px) + - Color: Same as message text, at reduced opacity or using `text_secondary` + +6. **Dismiss button**: Small text button labeled with an `x` character. + - Uses `ui.small_button("x")` + - Clicking sets the banner state to None + +### 3.3 Color Palette + +All colors are resolved through `DashColors` methods — the banner contains zero hardcoded color values. The banner uses a **tinted background + colored border + colored text** approach. + +#### Color Resolution via DashColors + +| Purpose | DashColors Method | Description | +|---|---|---| +| Text & border | `DashColors::message_color(type, dark_mode)` | Delegates to `error_color()` / `success_color()` / `warning_color()` / `info_color()` | +| Background tint | `DashColors::message_background_color(type, dark_mode)` | Severity color at 8% alpha (light) or 12% alpha (dark) | +| Countdown text | `DashColors::text_secondary(dark_mode)` | Standard secondary text color | + +#### Resolved Values (for visual reference) + +**Light Mode:** + +| Severity | Background | Text & Border | +|----------|-----------|---------------| +| Error | `ERROR` at 8% alpha | `DashColors::error_color(false)` → `DARK_RED` | +| Warning | `WARNING` at 8% alpha | `DashColors::warning_color(false)` → dark amber | +| Success | `SUCCESS` at 8% alpha | `DashColors::success_color(false)` → `DARK_GREEN` | +| Info | `INFO` at 8% alpha | `DashColors::info_color(false)` → `DEEP_BLUE` | + +**Dark Mode:** + +| Severity | Background | Text & Border | +|----------|-----------|---------------| +| Error | lighter red at 12% alpha | `DashColors::error_color(true)` → `rgb(255, 100, 100)` | +| Warning | lighter amber at 12% alpha | `DashColors::warning_color(true)` → `rgb(255, 200, 100)` | +| Success | muted green at 12% alpha | `DashColors::success_color(true)` → `rgb(80, 160, 80)` | +| Info | light blue at 12% alpha | `DashColors::info_color(true)` → `rgb(100, 180, 255)` | + +Dark mode uses higher alpha backgrounds (12% vs 8%) to maintain visibility against dark surfaces. + +#### Border Stroke +- Width: `Shape::BORDER_WIDTH` (1px) +- Color: `DashColors::message_color(type, dark_mode)` — same as text color + +### 3.4 Typography + +- Icon: `Typography::SCALE_BASE` (16px), bold (`RichText::strong()`) +- Message body: `Typography::body()` (16px), normal weight +- Countdown: `Typography::body_small()` (14px), normal weight, secondary text color +- Dismiss button: egui default `small_button` styling + +--- + +## 4. Placement + +The banner renders at the **top of the screen's content area**, before the `ScrollArea`. This matches the existing convention in `top_up_identity_screen/mod.rs:531-552` and `add_new_identity_screen/mod.rs:1071-1092`. + +``` ++--------------------------------------------------+ +| Top Panel (header / navigation) | ++--------------------------------------------------+ +| Left Panel | [ MessageBanner ] | <-- here +| | +----- ScrollArea -----+ | +| | | Screen content | | +| | | ... | | +| | +----------------------+ | ++--------------------------------------------------+ +``` + +The banner must be rendered **outside** the `ScrollArea` so it remains visible regardless of scroll position. This is consistent with current best practice in the codebase. + +--- + +## 5. Behavior + +### 5.1 Showing a Message + +Calling `banner.set_message("text", MessageType::Error)` replaces any currently displayed message. There is no queue. The new message immediately takes effect. + +For auto-dismissing types (Success, Info), the component records the timestamp when the message was set (using `Instant::now()` or egui frame time). + +### 5.2 Auto-Dismiss (Success, Info) + +- Duration: 5 seconds +- The countdown label shows remaining whole seconds: `(5s)`, `(4s)`, ..., `(1s)` +- When the timer expires, the message clears automatically on the next frame +- The screen must call `banner.show(ui)` each frame (standard egui immediate mode) +- The component internally checks elapsed time and clears itself + +### 5.3 Manual Dismiss + +- All severity types display a dismiss button +- Clicking the dismiss button immediately clears the message +- No confirmation needed + +### 5.4 Message Replacement + +- Setting a new message while one is showing replaces it immediately +- If the old message was Error (persistent) and the new one is Success (auto-dismiss), the Success behavior applies +- Timer resets on replacement + +### 5.5 Screen Navigation + +- When the user navigates away from a screen and returns, persistent messages (Error, Warning) should still be visible if the screen struct was retained (root screens in `main_screens` BTreeMap) +- Auto-dismiss messages that expired while the screen was not visible should be gone on return +- Modal/detail screens pushed onto `screen_stack` are destroyed when popped, so their messages naturally disappear + +--- + +## 6. Component API (Behavioral Spec) + +This describes the public interface the component should expose. Not a full Rust implementation, but a behavioral contract for the architect and implementer. + +``` +MessageBanner + State: + - message: Option<(String, MessageType, Instant)> + + Methods: + - new() -> Self // empty, no message + - set_message(text: &str, msg_type: MessageType) // set/replace message + - clear() // manually clear + - show(ui: &mut Ui) // render; auto-dismiss check happens here + + MessageType (unified, replaces both existing enums): + - Error + - Warning + - Success + - Info +``` + +The component does NOT implement the `Component` trait from `component_trait.rs` because it has no domain data to bind via `update()`. It is a simpler display-only widget. Screens call `show(ui)` and `set_message(...)` directly. + +--- + +## 7. Integration Points + +### 7.1 ScreenLike Trait + +The existing `display_message(&mut self, message: &str, message_type: MessageType)` method on `ScreenLike` is the integration point. Screens that adopt `MessageBanner` implement it as: + +``` +fn display_message(&mut self, message: &str, message_type: MessageType) { + self.banner.set_message(message, message_type); +} +``` + +### 7.2 Replacing Existing Fields + +Each screen replaces its ad-hoc fields: +- `error_message: Option` -> removed +- `info_message: Option` -> removed +- `message: Option<(String, MessageType)>` -> removed +- `backend_message: Option<(String, MessageType, DateTime)>` -> removed + +All replaced by a single: +- `banner: MessageBanner` + +### 7.3 Sync Error Display (Validation) + +For validation errors set during `ui()`: + +``` +if some_validation_fails { + self.banner.set_message("Invalid input: ...", MessageType::Error); +} +``` + +--- + +## 8. Edge Cases + +| Scenario | Behavior | +|----------|----------| +| Very long message (300+ chars) | Text wraps within the frame. Frame grows vertically. No truncation. | +| Empty string message | Treated as no message; banner not shown. | +| Rapid message replacement | Each call to `set_message` replaces immediately. No debounce. | +| Multiple error fields on one screen | All consolidated into a single banner. If multiple errors need display, concatenate them with newlines before calling `set_message`. | +| Screen with multiple independent sections | Each section could own its own `MessageBanner` if needed, but the default is one per screen. | +| Theme change while message is showing | Colors re-evaluate each frame via `ui.ctx().style().visuals.dark_mode`. No stale colors. | + +--- + +## 9. Accessibility + +- **Color contrast**: All text/background combinations meet WCAG 2.1 AA contrast ratio (4.5:1 minimum). The colored text on tinted backgrounds achieves this because the backgrounds are near-transparent (8-12% alpha) over the page background. +- **Not color-only**: The icon character provides a non-color severity indicator (distinct shapes for error vs warning vs success vs info). +- **Text is selectable**: egui labels allow text selection by default. +- **Keyboard**: The dismiss button is focusable and activatable via keyboard in egui's default tab-order. No special focus management needed. +- **Screen readers**: Not directly applicable (egui does not have native screen reader support), but the text content is programmatically accessible via egui's accessibility layer if enabled. + +--- + +## 10. What This Spec Does NOT Cover + +- Toast/notification stacking (out of scope -- one message per screen) +- Animation or transitions (egui does not support CSS-style transitions; show/hide is instant) +- Sound or haptic feedback +- Message persistence across app restarts +- Global overlay banners +- Changes to BackendTask/TaskResult/AppState architecture + +--- + +## 11. Migration Strategy (UX Perspective) + +The visual result after migration should be: +1. Every screen shows messages in exactly the same visual style +2. Error messages appear in the same position (top of content, before scroll area) +3. Success messages auto-clear after 5 seconds with a visible countdown +4. No more inline `colored_label` errors scattered at arbitrary positions in screen layouts +5. The Warning severity becomes available for the first time (currently missing from `MessageType` in `mod.rs`) + +Screens that currently use `colored_label` for inline validation hints (e.g., "Field is required") are a separate concern and should remain inline. The `MessageBanner` replaces only the screen-level status/result messages. From 7e167131516947961e49bc7f292d5a1941550eb1 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 18 Feb 2026 12:03:29 +0100 Subject: [PATCH 02/46] feat: add MessageType::Warning --- .../add_contracts_screen.rs | 9 +- .../group_actions_screen.rs | 9 +- src/ui/dashpay/add_contact_screen.rs | 1 + src/ui/dashpay/contact_details.rs | 1 + src/ui/dashpay/contact_info_editor.rs | 1 + src/ui/dashpay/contact_profile_viewer.rs | 1 + src/ui/dashpay/contact_requests.rs | 1 + src/ui/dashpay/contacts_list.rs | 1 + src/ui/dashpay/profile_screen.rs | 1 + src/ui/dashpay/profile_search.rs | 1 + src/ui/dashpay/qr_code_generator.rs | 1 + src/ui/dashpay/qr_scanner.rs | 1 + src/ui/dashpay/send_payment.rs | 6 ++ src/ui/dpns/dpns_contested_names_screen.rs | 1 + src/ui/mod.rs | 1 + src/ui/theme.rs | 90 ++++++++++++------- src/ui/tokens/add_token_by_id_screen.rs | 2 +- src/ui/tokens/tokens_screen/mod.rs | 1 + src/ui/tokens/update_token_config.rs | 2 +- src/ui/tokens/view_token_claims_screen.rs | 6 +- src/ui/tools/masternode_list_diff_screen.rs | 3 +- src/ui/tools/transition_visualizer_screen.rs | 2 +- src/ui/wallets/asset_lock_detail_screen.rs | 1 + src/ui/wallets/create_asset_lock_screen.rs | 1 + src/ui/wallets/send_screen.rs | 2 +- src/ui/wallets/single_key_send_screen.rs | 1 + src/ui/wallets/wallets_screen/mod.rs | 1 + 27 files changed, 96 insertions(+), 52 deletions(-) diff --git a/src/ui/contracts_documents/add_contracts_screen.rs b/src/ui/contracts_documents/add_contracts_screen.rs index a0d69c5c4..f2773351b 100644 --- a/src/ui/contracts_documents/add_contracts_screen.rs +++ b/src/ui/contracts_documents/add_contracts_screen.rs @@ -271,15 +271,10 @@ impl AddContractsScreen { impl ScreenLike for AddContractsScreen { fn display_message(&mut self, message: &str, message_type: MessageType) { match message_type { - MessageType::Success => { - // Not used - } - MessageType::Error => { + MessageType::Error | MessageType::Warning => { self.add_contracts_status = AddContractsStatus::ErrorMessage(message.to_string()); } - MessageType::Info => { - // Not used - } + MessageType::Success | MessageType::Info => {} } } diff --git a/src/ui/contracts_documents/group_actions_screen.rs b/src/ui/contracts_documents/group_actions_screen.rs index 278376cea..e2977864d 100644 --- a/src/ui/contracts_documents/group_actions_screen.rs +++ b/src/ui/contracts_documents/group_actions_screen.rs @@ -453,16 +453,11 @@ impl ScreenLike for GroupActionsScreen { fn display_message(&mut self, message: &str, message_type: MessageType) { match message_type { - MessageType::Success => { - // Not used - } - MessageType::Error => { + MessageType::Error | MessageType::Warning => { self.fetch_group_actions_status = FetchGroupActionsStatus::ErrorMessage(message.to_string()); } - MessageType::Info => { - // Not used - } + MessageType::Success | MessageType::Info => {} } } diff --git a/src/ui/dashpay/add_contact_screen.rs b/src/ui/dashpay/add_contact_screen.rs index f59ee79df..eb16e18f1 100644 --- a/src/ui/dashpay/add_contact_screen.rs +++ b/src/ui/dashpay/add_contact_screen.rs @@ -242,6 +242,7 @@ impl ScreenLike for AddContactScreen { 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); diff --git a/src/ui/dashpay/contact_details.rs b/src/ui/dashpay/contact_details.rs index c27d79fd0..5ca9f8e50 100644 --- a/src/ui/dashpay/contact_details.rs +++ b/src/ui/dashpay/contact_details.rs @@ -148,6 +148,7 @@ impl ContactDetailsScreen { 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); diff --git a/src/ui/dashpay/contact_info_editor.rs b/src/ui/dashpay/contact_info_editor.rs index 01bb458c1..d2b0d74d5 100644 --- a/src/ui/dashpay/contact_info_editor.rs +++ b/src/ui/dashpay/contact_info_editor.rs @@ -140,6 +140,7 @@ impl ContactInfoEditorScreen { 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); diff --git a/src/ui/dashpay/contact_profile_viewer.rs b/src/ui/dashpay/contact_profile_viewer.rs index 77c6e59e6..e6fcbac1b 100644 --- a/src/ui/dashpay/contact_profile_viewer.rs +++ b/src/ui/dashpay/contact_profile_viewer.rs @@ -230,6 +230,7 @@ impl ContactProfileViewerScreen { 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); diff --git a/src/ui/dashpay/contact_requests.rs b/src/ui/dashpay/contact_requests.rs index 2bc93b72c..9f6dfecb7 100644 --- a/src/ui/dashpay/contact_requests.rs +++ b/src/ui/dashpay/contact_requests.rs @@ -435,6 +435,7 @@ impl ContactRequests { 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 diff --git a/src/ui/dashpay/contacts_list.rs b/src/ui/dashpay/contacts_list.rs index 0abd74e1d..5192991ab 100644 --- a/src/ui/dashpay/contacts_list.rs +++ b/src/ui/dashpay/contacts_list.rs @@ -535,6 +535,7 @@ impl ContactsList { 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); diff --git a/src/ui/dashpay/profile_screen.rs b/src/ui/dashpay/profile_screen.rs index bb79b89b7..a97aac44b 100644 --- a/src/ui/dashpay/profile_screen.rs +++ b/src/ui/dashpay/profile_screen.rs @@ -661,6 +661,7 @@ impl ProfileScreen { 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); diff --git a/src/ui/dashpay/profile_search.rs b/src/ui/dashpay/profile_search.rs index 4b9779430..a22810f4e 100644 --- a/src/ui/dashpay/profile_search.rs +++ b/src/ui/dashpay/profile_search.rs @@ -122,6 +122,7 @@ impl ProfileSearchScreen { 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); diff --git a/src/ui/dashpay/qr_code_generator.rs b/src/ui/dashpay/qr_code_generator.rs index 10600240a..ac76a407e 100644 --- a/src/ui/dashpay/qr_code_generator.rs +++ b/src/ui/dashpay/qr_code_generator.rs @@ -150,6 +150,7 @@ impl QRCodeGeneratorScreen { 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); diff --git a/src/ui/dashpay/qr_scanner.rs b/src/ui/dashpay/qr_scanner.rs index 294180b4d..5eafeb4a6 100644 --- a/src/ui/dashpay/qr_scanner.rs +++ b/src/ui/dashpay/qr_scanner.rs @@ -128,6 +128,7 @@ impl QRScannerScreen { 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); diff --git a/src/ui/dashpay/send_payment.rs b/src/ui/dashpay/send_payment.rs index ceba08405..f5e9dd89c 100644 --- a/src/ui/dashpay/send_payment.rs +++ b/src/ui/dashpay/send_payment.rs @@ -185,6 +185,9 @@ impl SendPaymentScreen { 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); @@ -649,6 +652,9 @@ impl PaymentHistory { 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); diff --git a/src/ui/dpns/dpns_contested_names_screen.rs b/src/ui/dpns/dpns_contested_names_screen.rs index 3eaa52154..e749959af 100644 --- a/src/ui/dpns/dpns_contested_names_screen.rs +++ b/src/ui/dpns/dpns_contested_names_screen.rs @@ -2114,6 +2114,7 @@ impl ScreenLike for DPNSScreen { 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, }; diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 152be79fd..84b8ac96b 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -830,6 +830,7 @@ impl Screen { pub enum MessageType { Success, Info, + Warning, Error, } diff --git a/src/ui/theme.rs b/src/ui/theme.rs index b8e7cbc7e..dd186acfd 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -324,6 +324,66 @@ impl DashColors { } } + pub fn info_color(dark_mode: bool) -> Color32 { + if dark_mode { + Color32::from_rgb(100, 180, 255) // Lighter blue for dark mode + } else { + Self::DEEP_BLUE + } + } + + /// Returns the foreground (text/border) color for a message severity level. + pub fn message_color(message_type: crate::ui::MessageType, dark_mode: bool) -> Color32 { + match message_type { + crate::ui::MessageType::Error => Self::error_color(dark_mode), + crate::ui::MessageType::Warning => Self::warning_color(dark_mode), + crate::ui::MessageType::Success => Self::success_color(dark_mode), + crate::ui::MessageType::Info => Self::info_color(dark_mode), + } + } + + /// Returns the tinted background color for a message severity level. + pub fn message_background_color( + message_type: crate::ui::MessageType, + dark_mode: bool, + ) -> Color32 { + let alpha = if dark_mode { 30 } else { 20 }; + match message_type { + crate::ui::MessageType::Error => { + let c = if dark_mode { + (255, 100, 100) + } else { + (235, 87, 87) + }; + Color32::from_rgba_unmultiplied(c.0, c.1, c.2, alpha) + } + crate::ui::MessageType::Warning => { + let c = if dark_mode { + (255, 200, 100) + } else { + (241, 196, 15) + }; + Color32::from_rgba_unmultiplied(c.0, c.1, c.2, alpha) + } + crate::ui::MessageType::Success => { + let c = if dark_mode { + (80, 200, 120) + } else { + (39, 174, 96) + }; + Color32::from_rgba_unmultiplied(c.0, c.1, c.2, alpha) + } + crate::ui::MessageType::Info => { + let c = if dark_mode { + (100, 180, 255) + } else { + (52, 152, 219) + }; + Color32::from_rgba_unmultiplied(c.0, c.1, c.2, alpha) + } + } + } + pub fn muted_color(dark_mode: bool) -> Color32 { if dark_mode { Color32::from_rgb(150, 150, 150) // Lighter gray for dark mode @@ -809,33 +869,3 @@ pub fn apply_theme(ctx: &egui::Context, theme_mode: ThemeMode) { ctx.set_style(style); ctx.set_fonts(configure_fonts()); } - -/// Message type styling -#[allow(dead_code)] -pub enum MessageType { - Success, - Error, - Warning, - Info, -} - -#[allow(dead_code)] -impl MessageType { - pub fn color(&self) -> Color32 { - match self { - MessageType::Success => DashColors::SUCCESS, - MessageType::Error => DashColors::ERROR, - MessageType::Warning => DashColors::WARNING, - MessageType::Info => DashColors::INFO, - } - } - - pub fn background_color(&self) -> Color32 { - match self { - MessageType::Success => Color32::from_rgba_unmultiplied(39, 174, 96, 20), - MessageType::Error => Color32::from_rgba_unmultiplied(235, 87, 87, 20), - MessageType::Warning => Color32::from_rgba_unmultiplied(241, 196, 15, 20), - MessageType::Info => Color32::from_rgba_unmultiplied(52, 152, 219, 20), - } - } -} diff --git a/src/ui/tokens/add_token_by_id_screen.rs b/src/ui/tokens/add_token_by_id_screen.rs index 45185f97c..95d469c42 100644 --- a/src/ui/tokens/add_token_by_id_screen.rs +++ b/src/ui/tokens/add_token_by_id_screen.rs @@ -270,7 +270,7 @@ impl ScreenLike for AddTokenByIdScreen { self.status = AddTokenStatus::Error(msg.to_owned()); } } - MessageType::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()); diff --git a/src/ui/tokens/tokens_screen/mod.rs b/src/ui/tokens/tokens_screen/mod.rs index b9d097e9c..6f6039a7a 100644 --- a/src/ui/tokens/tokens_screen/mod.rs +++ b/src/ui/tokens/tokens_screen/mod.rs @@ -2942,6 +2942,7 @@ impl ScreenLike for TokensScreen { 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, }; diff --git a/src/ui/tokens/update_token_config.rs b/src/ui/tokens/update_token_config.rs index 82ea57b3f..c839d5eaa 100644 --- a/src/ui/tokens/update_token_config.rs +++ b/src/ui/tokens/update_token_config.rs @@ -1101,7 +1101,7 @@ impl ScreenLike for UpdateTokenConfigScreen { MessageType::Success => { ui.colored_label(Color32::DARK_GREEN, &msg); } - MessageType::Error => { + MessageType::Error | MessageType::Warning => { let dark_mode = ui.ctx().style().visuals.dark_mode; let error_color = DashColors::error_color(dark_mode); Frame::new() diff --git a/src/ui/tokens/view_token_claims_screen.rs b/src/ui/tokens/view_token_claims_screen.rs index cc9635c68..adc713c16 100644 --- a/src/ui/tokens/view_token_claims_screen.rs +++ b/src/ui/tokens/view_token_claims_screen.rs @@ -74,8 +74,8 @@ impl ScreenLike for ViewTokenClaimsScreen { MessageType::Success => { self.message = Some((message.to_string(), MessageType::Success, Utc::now())); } - MessageType::Error => { - self.message = Some((message.to_string(), MessageType::Error, 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; } @@ -153,7 +153,7 @@ impl ScreenLike for ViewTokenClaimsScreen { MessageType::Success => { ui.colored_label(DashColors::success_color(dark_mode), msg); } - MessageType::Error => { + MessageType::Error | MessageType::Warning => { ui.colored_label(DashColors::error_color(dark_mode), msg); } MessageType::Info => { diff --git a/src/ui/tools/masternode_list_diff_screen.rs b/src/ui/tools/masternode_list_diff_screen.rs index 8697d4552..41978be75 100644 --- a/src/ui/tools/masternode_list_diff_screen.rs +++ b/src/ui/tools/masternode_list_diff_screen.rs @@ -1565,6 +1565,7 @@ impl MasternodeListDiffScreen { 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, @@ -4181,7 +4182,7 @@ impl MasternodeListDiffScreen { impl ScreenLike for MasternodeListDiffScreen { fn display_message(&mut self, message: &str, message_type: MessageType) { match message_type { - MessageType::Error => { + MessageType::Error | MessageType::Warning => { self.task.pending = None; self.ui_state.error = Some(message.to_string()); } diff --git a/src/ui/tools/transition_visualizer_screen.rs b/src/ui/tools/transition_visualizer_screen.rs index 8faa82106..7cc157ee2 100644 --- a/src/ui/tools/transition_visualizer_screen.rs +++ b/src/ui/tools/transition_visualizer_screen.rs @@ -414,7 +414,7 @@ impl ScreenLike for TransitionVisualizerScreen { self.broadcast_status = TransitionBroadcastStatus::Complete(Instant::now()); } } - MessageType::Error => { + MessageType::Error | MessageType::Warning => { self.broadcast_status = TransitionBroadcastStatus::Error(message.to_string(), Instant::now()); } diff --git a/src/ui/wallets/asset_lock_detail_screen.rs b/src/ui/wallets/asset_lock_detail_screen.rs index 28bcf528b..53abb04a3 100644 --- a/src/ui/wallets/asset_lock_detail_screen.rs +++ b/src/ui/wallets/asset_lock_detail_screen.rs @@ -364,6 +364,7 @@ impl ScreenLike for AssetLockDetailScreen { 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, }; diff --git a/src/ui/wallets/create_asset_lock_screen.rs b/src/ui/wallets/create_asset_lock_screen.rs index c4456404a..b0d28d310 100644 --- a/src/ui/wallets/create_asset_lock_screen.rs +++ b/src/ui/wallets/create_asset_lock_screen.rs @@ -694,6 +694,7 @@ impl ScreenLike for CreateAssetLockScreen { 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, }; diff --git a/src/ui/wallets/send_screen.rs b/src/ui/wallets/send_screen.rs index bfd70e3dc..4e0c9f859 100644 --- a/src/ui/wallets/send_screen.rs +++ b/src/ui/wallets/send_screen.rs @@ -2672,7 +2672,7 @@ impl ScreenLike for WalletSendScreen { fn display_message(&mut self, message: &str, message_type: MessageType) { match message_type { - MessageType::Error => { + MessageType::Error | MessageType::Warning => { self.send_status = SendStatus::Error(message.to_string()); } MessageType::Success => { diff --git a/src/ui/wallets/single_key_send_screen.rs b/src/ui/wallets/single_key_send_screen.rs index e7c759c45..9528d9020 100644 --- a/src/ui/wallets/single_key_send_screen.rs +++ b/src/ui/wallets/single_key_send_screen.rs @@ -886,6 +886,7 @@ impl ScreenLike for SingleKeyWalletSendScreen { 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, }; diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index fa9550ebb..712cd42d4 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -1372,6 +1372,7 @@ impl ScreenLike for WalletsBalancesScreen { 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, }; From 75f09b5f2d790493a0ab8e1b8a995384394e2cf6 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 18 Feb 2026 16:50:41 +0100 Subject: [PATCH 03/46] chore: initial implementation --- CLAUDE.md | 4 + doc/COMPONENT_DESIGN_PATTERN.md | 22 + .../architecture.md | 88 ++++ src/app.rs | 17 + src/context/mod.rs | 9 + src/ui/components/message_banner.rs | 480 ++++++++++++++++++ src/ui/components/mod.rs | 2 + src/ui/components/styled.rs | 5 +- src/ui/identities/identities_screen.rs | 111 +--- .../by_platform_address.rs | 4 +- .../by_using_unused_asset_lock.rs | 22 +- .../by_using_unused_balance.rs | 36 +- .../by_wallet_qr_code.rs | 37 +- .../identities/top_up_identity_screen/mod.rs | 37 +- src/ui/tokens/mint_tokens_screen.rs | 66 +-- src/ui/tokens/tokens_screen/mod.rs | 3 + src/ui/wallets/wallets_screen/dialogs.rs | 1 + tests/kittest/main.rs | 1 + tests/kittest/message_banner.rs | 443 ++++++++++++++++ 19 files changed, 1188 insertions(+), 200 deletions(-) create mode 100644 src/ui/components/message_banner.rs create mode 100644 tests/kittest/message_banner.rs diff --git a/CLAUDE.md b/CLAUDE.md index 8f0ccd02c..45b392c84 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -152,6 +152,10 @@ response.inner.update(&mut self.amount); **Anti-patterns:** public mutable fields, eager initialization, not clearing invalid data +## 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. + ## Database Single SQLite connection wrapped in `Mutex`. Schema initialized in `database/initialization.rs`. Domain modules provide typed CRUD methods. Backend task errors are `Result` — string errors display directly to users. diff --git a/doc/COMPONENT_DESIGN_PATTERN.md b/doc/COMPONENT_DESIGN_PATTERN.md index d1a6d73df..0c247a073 100644 --- a/doc/COMPONENT_DESIGN_PATTERN.md +++ b/doc/COMPONENT_DESIGN_PATTERN.md @@ -98,3 +98,25 @@ impl ComponentResponse for MyComponentResponse { - Not clearing invalid data See `AmountInput` in `src/ui/components/amount_input.rs` for a complete example. + +--- + +## Display-Only Components + +Not all components produce domain values. **Display-only** components like `MessageBanner`, `StyledButton`, `GradientHeading`, and `InfoPopup` consume data for rendering and do not implement the `Component` trait. They follow the same structural conventions (private fields, `new()` constructor, builder methods, `show()`) but omit `ComponentResponse` and `update()`. + +### Global State via egui Context + +`MessageBanner` demonstrates a pattern for **app-wide UI state** stored in egui context data rather than in a screen struct. This is useful when the same widget must be accessible from multiple call sites (e.g., `AppState::update()` sets it, `island_central_panel()` renders it). + +```rust +// Setting from anywhere with &egui::Context +MessageBanner::set_global(ctx, "Error message", MessageType::Error); + +// Rendering from a central location +MessageBanner::show_global(ui); +``` + +Use this pattern sparingly. Per-screen ownership (the standard lazy-init pattern above) is preferred for most components. Global state is appropriate only when the rendering site and the state-setting site are architecturally separate (e.g., task result handling vs. screen rendering). + +See `src/ui/components/message_banner.rs` for the implementation. diff --git a/docs/ai-design/2026-02-17-unified-messages/architecture.md b/docs/ai-design/2026-02-17-unified-messages/architecture.md index c9afa675e..b51d5af16 100644 --- a/docs/ai-design/2026-02-17-unified-messages/architecture.md +++ b/docs/ai-design/2026-02-17-unified-messages/architecture.md @@ -744,3 +744,91 @@ Add an accessor method to the trait so `AppState` could call `screen.banner().se 8 screens already use `DateTime` for timed messages. **Rejected because:** `Instant` is monotonic (immune to system clock changes), simpler (no chrono dependency needed), and purpose-built for duration measurement. `DateTime` is correct for display timestamps but wrong for timeout logic. + +--- + +## 9. Implementation Notes + +This section documents how the implementation diverged from the original design and the patterns that emerged during development. + +### 9.1 Architecture Deviation: Global Banner Instead of Per-Screen + +The original design (Sections 2-7 above) specified **per-screen ownership** where each screen holds a `MessageBanner` instance. The implementation instead uses a **global banner** stored in egui context data and rendered centrally by `island_central_panel()`. + +**Why the change:** The user requested centralized error handling where backend task errors display automatically without requiring every screen to wire up `display_message()` overrides. With a global banner, `AppState::update()` sets the message once and it appears on whatever screen is currently visible. No per-screen rendering code is needed for basic error/success display. + +**Why the original concern was resolved:** Section 8, Alternative A rejected the global approach because "screens render inside panels with different layouts" and "a global banner can't know the correct placement." In practice, all 58 screens render through `island_central_panel()` (in `src/ui/components/styled.rs`), which provides a single consistent insertion point at the top of the content area, before screen content. This makes the placement concern moot. + +### 9.2 Global API + +The `MessageBanner` component (`src/ui/components/message_banner.rs`) exposes four static methods for global state management. State is stored in egui's temporary context data using `egui::Id::new("__global_message_banner")`. + +| Method | Signature | Purpose | +|--------|-----------|---------| +| `set_global` | `(ctx: &egui::Context, text: &str, message_type: MessageType)` | Sets or replaces the global banner message. Empty text clears. | +| `clear_global` | `(ctx: &egui::Context)` | Removes the global banner state from context data. | +| `show_global` | `(ui: &mut egui::Ui)` | Renders the banner, handles auto-dismiss and countdown. Called by `island_central_panel`. | +| `has_global` | `(ctx: &egui::Context) -> bool` | Checks whether a global message exists (useful for tests). | + +The underlying `BannerState` struct is `#[derive(Clone)]` because egui context data requires `Clone`. It stores `text: String`, `message_type: MessageType`, and `created_at: Instant`. + +### 9.3 AppState Integration + +`AppState::update()` in `src/app.rs` sets the global banner automatically for all task results: + +``` +TaskResult::Error(message) + -> MessageBanner::set_global(ctx, &message, MessageType::Error) + -> screen.display_message(&message, MessageType::Error) // for side-effects + +TaskResult::Success (default branch) + -> MessageBanner::set_global(ctx, "Success", MessageType::Success) + -> screen.display_task_result(result) + +TaskResult::Success (specific: UpdatedThemePreference) + -> MessageBanner::set_global(ctx, "Theme preference updated successfully", MessageType::Success) + +TaskResult::Success (specific: CastScheduledVote) + -> MessageBanner::set_global(ctx, "Successfully cast scheduled vote", MessageType::Success) +``` + +The call to `screen.display_message()` is retained alongside `set_global` so that screens can still perform side-effects (e.g., resetting step state on error) without being responsible for rendering. + +### 9.4 Screen Migration Pattern + +Migrated screens follow this pattern: + +1. **Remove error display fields** (`error_message: Option`, `backend_message: Option<(String, MessageType, DateTime)>`, etc.) +2. **Remove rendering code** (the `Frame`/`colored_label` blocks in `ui()`) +3. **Simplify `display_message()`** to only retain side-effect logic (e.g., resetting step state). The method no longer needs to store the error text since the global banner handles display. +4. **Add `pending_banner_message` field** for messages that originate in `display_task_result()`, which does not receive the egui `Context`. These are flushed at the top of `ui()`: + +```rust +// In display_task_result(): +self.pending_banner_message = Some(("Successfully refreshed identity".to_string(), MessageType::Success)); + +// At the top of ui(): +if let Some((text, msg_type)) = self.pending_banner_message.take() { + MessageBanner::set_global(ctx, &text, msg_type); +} +``` + +For validation errors set during `ui()`, screens call `MessageBanner::set_global(ctx, ...)` directly since they have the egui `Context`. + +### 9.5 Per-Instance API + +The `MessageBanner` struct with per-instance methods (`new()`, `set_message()`, `clear()`, `show()`, `has_message()`) still exists alongside the global API. It shares rendering logic with the global path via a private `render_banner()` function. + +This per-instance API is available for potential future use cases such as screen-local banners displayed alongside the global one (e.g., a screen that needs a secondary notification area). Currently no screens use the per-instance API; its methods carry `#[allow(dead_code)]`. + +### 9.6 Migration Status + +3 of ~50 screens have been migrated as a proof-of-concept, each demonstrating a different migration path: + +| Screen | Pattern | Migration Notes | +|--------|---------|-----------------| +| `TopUpIdentityScreen` | A (Framed Banner + Dismiss) | Removed `error_message` field and 20-line Frame rendering block. `display_message()` retains step-reset side-effect. | +| `IdentitiesScreen` | B (Timed Badge with DateTime) | Removed `backend_message` field, `check_message_expiration()`, and `dismiss_message()` helpers. Uses `pending_banner_message` for messages from `display_task_result()`. | +| `MintTokensScreen` | D (Bare colored_label + status enum) | Changed `MintTokensStatus::ErrorMessage(String)` to `MintTokensStatus::Error` (no payload). Uses `pending_banner_message` for validation errors set during construction and in `confirmation_ok()`. | + +The remaining ~47 screens continue to use their existing per-screen error rendering. Old and new patterns coexist without conflict because the global banner renders above screen content via `island_central_panel`. diff --git a/src/app.rs b/src/app.rs index c11d96189..b09102ba6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -11,6 +11,7 @@ use crate::context::connection_status::ConnectionStatus; use crate::database::Database; use crate::logging::initialize_logger; use crate::model::settings::Settings; +use crate::ui::components::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::{ @@ -192,6 +193,7 @@ impl AppState { password_info.clone(), subtasks.clone(), connection_status.clone(), + ctx.clone(), ) { Some(context) => context, None => { @@ -207,6 +209,7 @@ impl AppState { password_info.clone(), subtasks.clone(), connection_status.clone(), + ctx.clone(), ); let devnet_app_context = AppContext::new( Network::Devnet, @@ -214,6 +217,7 @@ impl AppState { password_info.clone(), subtasks.clone(), connection_status.clone(), + ctx.clone(), ); let local_app_context = AppContext::new( Network::Regtest, @@ -221,6 +225,7 @@ impl AppState { password_info, subtasks.clone(), connection_status.clone(), + ctx.clone(), ); // load fonts @@ -827,6 +832,11 @@ impl App for AppState { } BackendTaskSuccessResult::UpdatedThemePreference(new_theme) => { self.theme_preference = new_theme; + MessageBanner::set_global( + ctx, + "Theme preference updated successfully", + MessageType::Success, + ); self.visible_screen_mut().display_message( "Theme preference updated successfully", MessageType::Success, @@ -837,6 +847,11 @@ impl App for AppState { vote.voter_id.as_slice(), vote.contested_name.clone(), ); + MessageBanner::set_global( + ctx, + "Successfully cast scheduled vote", + MessageType::Success, + ); self.visible_screen_mut().display_message( "Successfully cast scheduled vote", MessageType::Success, @@ -844,12 +859,14 @@ impl App for AppState { self.visible_screen_mut().refresh(); } _ => { + MessageBanner::set_global(ctx, "Success", MessageType::Success); self.visible_screen_mut() .display_task_result(unboxed_message); } } } TaskResult::Error(message) => { + MessageBanner::set_global(ctx, &message, MessageType::Error); self.visible_screen_mut() .display_message(&message, MessageType::Error); } diff --git a/src/context/mod.rs b/src/context/mod.rs index 3ccaf835d..0abf915fd 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -97,6 +97,9 @@ pub struct AppContext { /// Cached fee multiplier permille from current epoch (1000 = 1x, 2000 = 2x) /// Updated when epoch info is fetched from Platform fee_multiplier_permille: AtomicU64, + /// The egui context, stored for use in non-UI code paths (e.g. display_task_result). + /// Clone is O(1) — egui::Context is Arc-backed and the same instance for the app lifetime. + egui_ctx: egui::Context, } impl AppContext { @@ -106,6 +109,7 @@ impl AppContext { password_info: Option, subtasks: Arc, connection_status: Arc, + egui_ctx: egui::Context, ) -> Option> { let config = match Config::load() { Ok(config) => config, @@ -270,6 +274,7 @@ impl AppContext { fee_multiplier_permille: AtomicU64::new( PlatformFeeEstimator::DEFAULT_FEE_MULTIPLIER_PERMILLE, ), + egui_ctx, }; let app_context = Arc::new(app_context); @@ -341,6 +346,10 @@ impl AppContext { &self.connection_status } + pub fn egui_ctx(&self) -> &egui::Context { + &self.egui_ctx + } + pub fn set_core_backend_mode(self: &Arc, mode: CoreBackendMode) { self.core_backend_mode .store(mode.as_u8(), Ordering::Relaxed); diff --git a/src/ui/components/message_banner.rs b/src/ui/components/message_banner.rs new file mode 100644 index 000000000..b7e4d915b --- /dev/null +++ b/src/ui/components/message_banner.rs @@ -0,0 +1,480 @@ +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::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant}; + +const DEFAULT_AUTO_DISMISS: Duration = Duration::from_secs(5); +const MAX_BANNERS: usize = 5; +const BANNER_STATE_ID: &str = "__global_message_banner"; + +/// Monotonic counter for generating unique banner keys. +static BANNER_KEY_COUNTER: AtomicU64 = AtomicU64::new(0); + +fn next_banner_key() -> u64 { + BANNER_KEY_COUNTER.fetch_add(1, Ordering::Relaxed) +} + +/// The domain type for `MessageBanner`, representing the banner's lifecycle state. +#[derive(Clone, Debug, PartialEq)] +pub enum BannerStatus { + /// The banner is currently visible. + Visible, + /// The user clicked the dismiss button. + Dismissed, + /// The banner expired via auto-dismiss. + TimedOut, +} + +/// Response returned by `MessageBanner::show()` via the `Component` trait. +#[derive(Clone)] +pub struct MessageBannerResponse { + pub status: Option, + changed: bool, +} + +impl ComponentResponse for MessageBannerResponse { + type DomainType = BannerStatus; + + fn has_changed(&self) -> bool { + self.changed + } + + fn is_valid(&self) -> bool { + true + } + + fn changed_value(&self) -> &Option { + &self.status + } + + fn error_message(&self) -> Option<&str> { + None + } +} + +#[derive(Clone)] +struct BannerState { + key: u64, + text: String, + message_type: MessageType, + created_at: Instant, + /// When `Some`, the banner auto-dismisses after this duration. + /// `None` means the banner persists until manually dismissed. + auto_dismiss_after: Option, + /// When `true`, display elapsed time since creation instead of countdown. + show_elapsed: bool, +} + +/// Handle for a global banner, returned by [`MessageBanner::set_global`] and +/// [`MessageBanner::replace_global`]. Identifies the banner by an internal key, +/// so the display text can be updated without losing the reference. +/// +/// The handle is `'static` and safe to store. Methods that modify the banner +/// (`set_text`, `with_auto_dismiss`) take `&self` so the handle can be reused. +pub struct BannerHandle { + ctx: egui::Context, + key: u64, +} + +impl BannerHandle { + /// Returns how long ago this banner was created, looked up from context data. + /// Returns `None` if the banner no longer exists. + pub fn elapsed(&self) -> Option { + let banners = get_banners(&self.ctx); + banners + .iter() + .find(|b| b.key == self.key) + .map(|b| b.created_at.elapsed()) + } + + /// Update the display text of this banner. + /// Returns `None` if the banner no longer exists. + pub fn set_message(&self, text: &str) -> Option<&Self> { + let mut banners = get_banners(&self.ctx); + let found = if let Some(b) = banners.iter_mut().find(|b| b.key == self.key) { + b.text = text.to_string(); + true + } else { + false + }; + set_banners(&self.ctx, banners); + found.then_some(self) + } + + /// Override the auto-dismiss duration for this banner. + /// Resets the countdown timer. + /// Returns `None` if the banner no longer exists. + pub fn with_auto_dismiss(&self, duration: Duration) -> Option<&Self> { + let mut banners = get_banners(&self.ctx); + let found = if let Some(b) = banners.iter_mut().find(|b| b.key == self.key) { + b.auto_dismiss_after = Some(duration); + b.created_at = Instant::now(); + true + } else { + false + }; + set_banners(&self.ctx, banners); + found.then_some(self) + } + + /// Enable elapsed-time display on this banner. Disables auto-dismiss + /// and shows how long the banner has been visible (e.g. for long-running operations). + /// Returns `None` if the banner no longer exists. + pub fn with_elapsed(&self) -> Option<&Self> { + let mut banners = get_banners(&self.ctx); + let found = if let Some(b) = banners.iter_mut().find(|b| b.key == self.key) { + b.show_elapsed = true; + b.auto_dismiss_after = None; + true + } else { + false + }; + set_banners(&self.ctx, banners); + found.then_some(self) + } + + /// Remove this banner immediately. + pub fn clear(self) { + let mut banners = get_banners(&self.ctx); + banners.retain(|b| b.key != self.key); + set_banners(&self.ctx, banners); + } +} + +/// A banner widget for displaying screen-level messages. +/// +/// Supports two modes: +/// - **Global**: State stored in egui context data, rendered by `island_central_panel`. +/// Use `set_global`, `clear_global_message`, `show_global`, `has_global`. +/// - **Per-instance**: Each screen owns a `MessageBanner` and calls `show()`. +/// +/// Follows component conventions: private fields, `new()` constructor, builder methods. +pub struct MessageBanner { + state: Option, +} + +impl MessageBanner { + /// Creates an empty banner (no message displayed). + pub fn new() -> Self { + Self { state: None } + } + + /// 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 { + if text.is_empty() { + self.state = None; + } else { + self.state = Some(BannerState { + key: next_banner_key(), + text: text.to_string(), + message_type, + created_at: Instant::now(), + auto_dismiss_after: default_auto_dismiss(message_type), + show_elapsed: false, + }); + } + self + } + + /// Override the auto-dismiss duration for the current message. + /// Resets the countdown timer. No-op if no message is set. + #[allow(dead_code)] + pub fn set_auto_dismiss(&mut self, duration: Duration) -> &mut Self { + if let Some(state) = &mut self.state { + state.auto_dismiss_after = Some(duration); + state.created_at = Instant::now(); + } + self + } + + /// Clears the current message immediately. + #[allow(dead_code)] + pub fn clear(&mut self) { + self.state = None; + } + + /// Returns whether a message is currently displayed. + pub fn has_message(&self) -> bool { + self.state.is_some() + } + + // -- Global API (egui context data) -- + // + // Multiple messages can be displayed simultaneously. Messages are + // deduplicated by text — calling `set_global` with the same text twice + // results in a single banner. Use `replace_global` to swap one message + // for another (e.g., replacing a generic "Success" with a specific one). + + /// Adds a global banner message if one with the same text does not already exist. + /// 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 { + let mut banners = get_banners(ctx); + if let Some(existing) = banners.iter().find(|b| b.text == text) { + let key = existing.key; + return BannerHandle { + ctx: ctx.clone(), + key, + }; + } + let key = next_banner_key(); + if !text.is_empty() { + banners.push(BannerState { + key, + text: text.to_string(), + message_type, + created_at: Instant::now(), + auto_dismiss_after: default_auto_dismiss(message_type), + show_elapsed: false, + }); + if banners.len() > MAX_BANNERS { + banners.remove(0); + } + set_banners(ctx, banners); + } + BannerHandle { + ctx: ctx.clone(), + key, + } + } + + /// 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). + /// + /// Returns a [`BannerHandle`] for updating or clearing the banner later. + pub fn replace_global( + ctx: &egui::Context, + old_text: &str, + new_text: &str, + message_type: MessageType, + ) -> BannerHandle { + if new_text.is_empty() { + Self::clear_global_message(ctx, old_text); + return BannerHandle { + ctx: ctx.clone(), + key: next_banner_key(), + }; + } + let mut banners = get_banners(ctx); + let key; + if let Some(b) = banners.iter_mut().find(|b| b.text == old_text) { + key = b.key; + b.text = new_text.to_string(); + b.message_type = message_type; + b.created_at = Instant::now(); + b.auto_dismiss_after = default_auto_dismiss(message_type); + } else if let Some(existing) = banners.iter().find(|b| b.text == new_text) { + key = existing.key; + } else { + key = next_banner_key(); + banners.push(BannerState { + key, + text: new_text.to_string(), + message_type, + created_at: Instant::now(), + auto_dismiss_after: default_auto_dismiss(message_type), + show_elapsed: false, + }); + if banners.len() > MAX_BANNERS { + banners.remove(0); + } + } + set_banners(ctx, banners); + BannerHandle { + ctx: ctx.clone(), + key, + } + } + + /// Clears the specific global banner message matching `text`. + pub fn clear_global_message(ctx: &egui::Context, text: &str) { + let mut banners = get_banners(ctx); + banners.retain(|b| b.text != text); + set_banners(ctx, banners); + } + + /// Returns whether any global banner messages exist. + #[allow(dead_code)] + pub fn has_global(ctx: &egui::Context) -> bool { + !get_banners(ctx).is_empty() + } + + /// Renders all global banners from egui context data. + /// Call inside `island_central_panel` before content. + pub fn show_global(ui: &mut egui::Ui) { + let mut banners = get_banners(ui.ctx()); + if banners.is_empty() { + return; + } + banners.retain(|b| process_banner(ui, b) == BannerStatus::Visible); + set_banners(ui.ctx(), banners); + } +} + +impl Default for MessageBanner { + fn default() -> Self { + Self::new() + } +} + +impl Component for MessageBanner { + type DomainType = BannerStatus; + type Response = MessageBannerResponse; + + fn show(&mut self, ui: &mut egui::Ui) -> InnerResponse { + let Some(state) = &self.state else { + return empty_response(ui); + }; + let status = process_banner(ui, state); + if status != BannerStatus::Visible { + self.state = None; + } + let changed = status != BannerStatus::Visible; + InnerResponse::new( + MessageBannerResponse { + status: Some(status), + changed, + }, + ui.allocate_response(egui::Vec2::ZERO, egui::Sense::hover()), + ) + } + + fn current_value(&self) -> Option { + if self.state.is_some() { + Some(BannerStatus::Visible) + } else { + None + } + } +} + +/// Returns the default auto-dismiss duration for a message type. +/// `Success` and `Info` auto-dismiss; `Error` and `Warning` persist. +fn default_auto_dismiss(message_type: MessageType) -> Option { + match message_type { + MessageType::Success | MessageType::Info => Some(DEFAULT_AUTO_DISMISS), + MessageType::Error | MessageType::Warning => None, + } +} + +/// Helper for the empty-state return in `Component::show()`. +fn empty_response(ui: &mut egui::Ui) -> InnerResponse { + InnerResponse::new( + MessageBannerResponse { + status: None, + changed: false, + }, + ui.allocate_response(egui::Vec2::ZERO, egui::Sense::hover()), + ) +} + +/// Processes a single banner: checks expiry, renders, handles dismiss, requests repaint. +/// Returns the banner's resulting status. +fn process_banner(ui: &mut egui::Ui, state: &BannerState) -> BannerStatus { + let elapsed = state.created_at.elapsed(); + + // Check auto-dismiss expiry + if let Some(duration) = state.auto_dismiss_after + && elapsed >= duration + { + return BannerStatus::TimedOut; + } + + // Compute the right-side time annotation + let annotation = if state.show_elapsed { + Some(format!("({}s)", elapsed.as_secs())) + } else if let Some(duration) = state.auto_dismiss_after { + let remaining = duration.saturating_sub(elapsed); + Some(format!("({}s)", remaining.as_secs() + 1)) + } else { + None + }; + + if render_banner(ui, &state.text, state.message_type, annotation.as_deref()) { + return BannerStatus::Dismissed; + } + if state.auto_dismiss_after.is_some() || state.show_elapsed { + ui.ctx().request_repaint_after(Duration::from_secs(1)); + } + BannerStatus::Visible +} + +/// Shared rendering logic for both global and per-instance banners. +/// Returns `true` if the dismiss button was clicked. +fn render_banner( + ui: &mut egui::Ui, + text: &str, + message_type: MessageType, + annotation: Option<&str>, +) -> bool { + let dark_mode = ui.ctx().style().visuals.dark_mode; + let fg_color = DashColors::message_color(message_type, dark_mode); + let bg_color = DashColors::message_background_color(message_type, dark_mode); + let secondary_color = DashColors::text_secondary(dark_mode); + + let icon = icon_for_type(message_type); + let mut dismissed = false; + + egui::Frame::new() + .fill(bg_color) + .inner_margin(egui::Margin::symmetric(10, 8)) + .corner_radius(Shape::RADIUS_SM as f32) + .stroke(egui::Stroke::new(Shape::BORDER_WIDTH, fg_color)) + .show(ui, |ui| { + ui.horizontal(|ui| { + // Icon + ui.label(egui::RichText::new(icon).color(fg_color).strong()); + ui.add_space(Spacing::XS); + + // Message text + ui.label(egui::RichText::new(text).color(fg_color)); + + // Right-aligned: annotation + dismiss + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.small_button("x").clicked() { + dismissed = true; + } + + if let Some(annotation) = annotation { + ui.label( + egui::RichText::new(annotation) + .font(Typography::body_small()) + .color(secondary_color), + ); + } + }); + }); + }); + ui.add_space(Spacing::SM); + + dismissed +} + +/// Reads the global banner list from egui context data. +fn get_banners(ctx: &egui::Context) -> Vec { + ctx.data(|d| d.get_temp::>(egui::Id::new(BANNER_STATE_ID))) + .unwrap_or_default() +} + +/// Writes the global banner list to egui context data. +/// Removes the entry entirely when the list is empty. +fn set_banners(ctx: &egui::Context, banners: Vec) { + if banners.is_empty() { + ctx.data_mut(|d| d.remove::>(egui::Id::new(BANNER_STATE_ID))); + } else { + ctx.data_mut(|d| d.insert_temp(egui::Id::new(BANNER_STATE_ID), banners)); + } +} + +fn icon_for_type(message_type: MessageType) -> &'static str { + match message_type { + MessageType::Error => "\u{26A0}", // warning sign + MessageType::Warning => "\u{26A0}", // warning sign (differentiated by color) + MessageType::Success => "\u{2713}", // check mark + MessageType::Info => "\u{2139}", // info + } +} diff --git a/src/ui/components/mod.rs b/src/ui/components/mod.rs index d25b6bdcc..1c71238c4 100644 --- a/src/ui/components/mod.rs +++ b/src/ui/components/mod.rs @@ -9,6 +9,7 @@ pub mod identity_selector; pub mod info_popup; pub mod left_panel; pub mod left_wallet_panel; +pub mod message_banner; pub mod styled; pub mod tokens_subscreen_chooser_panel; pub mod tools_subscreen_chooser_panel; @@ -18,3 +19,4 @@ 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}; diff --git a/src/ui/components/styled.rs b/src/ui/components/styled.rs index 3d0a2fbb9..a4821c763 100644 --- a/src/ui/components/styled.rs +++ b/src/ui/components/styled.rs @@ -560,7 +560,10 @@ pub fn island_central_panel(ctx: &Context, content: impl FnOnce(&mut Ui) -> R .inner_margin(Margin::same(inner_margin as i8)) .corner_radius(egui::CornerRadius::same(Shape::RADIUS_LG)) .shadow(Shadow::elevated()) - .show(ui, |ui| content(ui)) + .show(ui, |ui| { + super::MessageBanner::show_global(ui); + content(ui) + }) .inner }) .inner diff --git a/src/ui/identities/identities_screen.rs b/src/ui/identities/identities_screen.rs index 8e063c0d2..693663bae 100644 --- a/src/ui/identities/identities_screen.rs +++ b/src/ui/identities/identities_screen.rs @@ -11,6 +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::identities::keys::add_key_screen::AddKeyScreen; use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; use crate::ui::identities::register_dpns_name_screen::{ @@ -20,7 +21,6 @@ use crate::ui::identities::top_up_identity_screen::TopUpIdentityScreen; use crate::ui::identities::transfer_screen::TransferScreen; use crate::ui::theme::DashColors; use crate::ui::{MessageType, RootScreenType, Screen, ScreenLike, ScreenType}; -use chrono::{DateTime, Utc}; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dash_sdk::dpp::identity::{Purpose, SecurityLevel}; @@ -52,12 +52,6 @@ enum IdentitiesSortOrder { Descending, } -#[derive(PartialEq)] -enum IdentitiesRefreshingStatus { - Refreshing(u64), - NotRefreshing, -} - pub struct IdentitiesScreen { pub identities: Arc>>, pub app_context: Arc, @@ -66,8 +60,7 @@ pub struct IdentitiesScreen { sort_column: IdentitiesSortColumn, sort_order: IdentitiesSortOrder, use_custom_order: bool, - refreshing_status: IdentitiesRefreshingStatus, - backend_message: Option<(String, MessageType, DateTime)>, + refresh_banner: Option, // Alias editing state editing_alias_identity: Option, editing_alias_value: String, @@ -92,8 +85,7 @@ impl IdentitiesScreen { sort_column: IdentitiesSortColumn::Alias, sort_order: IdentitiesSortOrder::Ascending, use_custom_order: true, - refreshing_status: IdentitiesRefreshingStatus::NotRefreshing, - backend_message: None, + refresh_banner: None, editing_alias_identity: None, editing_alias_value: String::new(), }; @@ -447,7 +439,7 @@ impl IdentitiesScreen { /// Returns Some `AppAction` if any identity needs to be refreshed, /// otherwise returns None. pub fn ensure_identities_status(&self, identities: &[QualifiedIdentity]) -> Option { - if self.refreshing_status != IdentitiesRefreshingStatus::NotRefreshing { + if self.refresh_banner.is_some() { // avoid refresh loop return None; } @@ -1068,22 +1060,6 @@ impl IdentitiesScreen { AppAction::None } - - fn dismiss_message(&mut self) { - self.backend_message = None; - } - - fn check_message_expiration(&mut self) { - if let Some((_, _, timestamp)) = &self.backend_message { - let now = Utc::now(); - let elapsed = now.signed_duration_since(*timestamp); - - // Automatically dismiss the message after 10 seconds - if elapsed.num_seconds() >= 10 { - self.dismiss_message(); - } - } - } } impl ScreenLike for IdentitiesScreen { @@ -1105,11 +1081,13 @@ impl ScreenLike for IdentitiesScreen { } } - fn display_message(&mut self, message: &str, message_type: crate::ui::MessageType) { - if let crate::ui::MessageType::Error = message_type { - self.refreshing_status = IdentitiesRefreshingStatus::NotRefreshing; + fn display_message(&mut self, _message: &str, message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. + if let MessageType::Error = message_type + && let Some(handle) = self.refresh_banner.take() + { + handle.clear(); } - self.backend_message = Some((message.to_string(), message_type, Utc::now())); } fn display_task_result( @@ -1119,18 +1097,18 @@ impl ScreenLike for IdentitiesScreen { if let crate::ui::BackendTaskSuccessResult::RefreshedIdentity(_) = backend_task_success_result { - self.refreshing_status = IdentitiesRefreshingStatus::NotRefreshing; - self.backend_message = Some(( - "Successfully refreshed identity".to_string(), - crate::ui::MessageType::Success, - Utc::now(), - )); + if let Some(handle) = self.refresh_banner.take() { + handle.clear(); + } + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Successfully refreshed identity", + MessageType::Success, + ); } } fn ui(&mut self, ctx: &Context) -> AppAction { - self.check_message_expiration(); - let mut right_buttons = if !self.app_context.has_wallet.load(Ordering::Relaxed) { vec![ ( @@ -1202,54 +1180,19 @@ impl ScreenLike for IdentitiesScreen { inner_action |= self.show_alias_edit_popup(ctx); } - // Show either refreshing indicator or message, but not both - if let IdentitiesRefreshingStatus::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 taken so far: {}", elapsed)); - ui.add(egui::widgets::Spinner::default().color(DashColors::DASH_BLUE)); - }); - ui.add_space(2.0); // Space below - } else if let Some((message, message_type, timestamp)) = self.backend_message.clone() { - let dark_mode = ui.ctx().style().visuals.dark_mode; - let message_color = match message_type { - MessageType::Error => egui::Color32::DARK_RED, - MessageType::Info => DashColors::text_primary(dark_mode), - MessageType::Success => egui::Color32::DARK_GREEN, - }; - - ui.add_space(25.0); // Same space as refreshing indicator - 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)", message, remaining); - ui.label(egui::RichText::new(full_msg).color(message_color)); - }); - ui.add_space(2.0); // Same space below as refreshing indicator - } inner_action }); match action { - AppAction::BackendTask(BackendTask::IdentityTask(IdentityTask::RefreshIdentity(_))) => { - self.refreshing_status = - IdentitiesRefreshingStatus::Refreshing(Utc::now().timestamp() as u64); - self.backend_message = None // Clear any existing message - } - AppAction::BackendTasks(_, _) => { - // Going to assume this is only going to be Refresh All - self.refreshing_status = - IdentitiesRefreshingStatus::Refreshing(Utc::now().timestamp() as u64); - self.backend_message = None // Clear any existing message + AppAction::BackendTask(BackendTask::IdentityTask(IdentityTask::RefreshIdentity(_))) + | AppAction::BackendTasks(_, _) => { + if let Some(handle) = self.refresh_banner.take() { + handle.clear(); + } + let handle = + MessageBanner::set_global(ctx, "Refreshing identities...", MessageType::Info); + handle.with_elapsed(); + self.refresh_banner = Some(handle); } _ => {} } diff --git a/src/ui/identities/top_up_identity_screen/by_platform_address.rs b/src/ui/identities/top_up_identity_screen/by_platform_address.rs index b10723693..59d2f21fb 100644 --- a/src/ui/identities/top_up_identity_screen/by_platform_address.rs +++ b/src/ui/identities/top_up_identity_screen/by_platform_address.rs @@ -4,6 +4,8 @@ use crate::backend_task::identity::IdentityTask; use crate::model::amount::Amount; use crate::model::fee_estimation::format_credits_as_dash; use crate::model::wallet::WalletSeedHash; +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::identities::funding_common::WalletFundedScreenStep; @@ -199,7 +201,7 @@ impl TopUpIdentityScreen { action = top_up_action; } Err(e) => { - self.error_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } } } diff --git a/src/ui/identities/top_up_identity_screen/by_using_unused_asset_lock.rs b/src/ui/identities/top_up_identity_screen/by_using_unused_asset_lock.rs index ed562bcfa..f4854c908 100644 --- a/src/ui/identities/top_up_identity_screen/by_using_unused_asset_lock.rs +++ b/src/ui/identities/top_up_identity_screen/by_using_unused_asset_lock.rs @@ -129,24 +129,20 @@ impl TopUpIdentityScreen { .frame(true) .corner_radius(3.0); if ui.add(button).clicked() { - self.error_message = None; action |= self.top_up_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 <="); - } - WalletFundedScreenStep::Success => { - ui.heading("...Success..."); - } - _ => {} - }); - } + ui.vertical_centered(|ui| match step { + WalletFundedScreenStep::WaitingForPlatformAcceptance => { + ui.heading("=> Waiting for Platform acknowledgement <="); + } + WalletFundedScreenStep::Success => { + ui.heading("...Success..."); + } + _ => {} + }); ui.add_space(40.0); action diff --git a/src/ui/identities/top_up_identity_screen/by_using_unused_balance.rs b/src/ui/identities/top_up_identity_screen/by_using_unused_balance.rs index d2242f8b2..5f1227baa 100644 --- a/src/ui/identities/top_up_identity_screen/by_using_unused_balance.rs +++ b/src/ui/identities/top_up_identity_screen/by_using_unused_balance.rs @@ -82,31 +82,27 @@ impl TopUpIdentityScreen { .frame(true) .corner_radius(3.0); if ui.add(button).clicked() { - self.error_message = None; action = self.top_up_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( - "=> Waiting for Core Chain to produce proof of transfer of funds. <=", - ); - } - WalletFundedScreenStep::WaitingForPlatformAcceptance => { - ui.heading("=> Waiting for Platform acknowledgement <="); - } - WalletFundedScreenStep::Success => { - ui.heading("...Success..."); - } - _ => {} - }; - }); - } + ui.vertical_centered(|ui| { + match step { + WalletFundedScreenStep::WaitingForAssetLock => { + ui.heading( + "=> Waiting for Core Chain to produce proof of transfer of funds. <=", + ); + } + WalletFundedScreenStep::WaitingForPlatformAcceptance => { + ui.heading("=> Waiting for Platform acknowledgement <="); + } + WalletFundedScreenStep::Success => { + ui.heading("...Success..."); + } + _ => {} + }; + }); ui.add_space(40.0); action diff --git a/src/ui/identities/top_up_identity_screen/by_wallet_qr_code.rs b/src/ui/identities/top_up_identity_screen/by_wallet_qr_code.rs index 12339a127..4c916473f 100644 --- a/src/ui/identities/top_up_identity_screen/by_wallet_qr_code.rs +++ b/src/ui/identities/top_up_identity_screen/by_wallet_qr_code.rs @@ -1,6 +1,8 @@ use crate::app::AppAction; use crate::backend_task::BackendTask; use crate::backend_task::identity::{IdentityTask, IdentityTopUpInfo, TopUpIdentityFundingMethod}; +use crate::ui::MessageType; +use crate::ui::components::MessageBanner; use crate::ui::identities::funding_common::{self, copy_to_clipboard, generate_qr_code_image}; use crate::ui::identities::top_up_identity_screen::{TopUpIdentityScreen, WalletFundedScreenStep}; use dash_sdk::dashcore_rpc::RpcApi; @@ -126,7 +128,7 @@ impl TopUpIdentityScreen { if let Ok(amount_dash) = self.funding_amount.parse::() { if amount_dash > 0.0 { if let Err(e) = self.render_qr_code(ui, amount_dash) { - self.error_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } } else { ui.label("Please enter an amount greater than 0"); @@ -173,25 +175,22 @@ impl TopUpIdentityScreen { } } - // Only show status messages if there's no error - if self.error_message.is_none() { - match step { - WalletFundedScreenStep::WaitingOnFunds => { - ui.heading("=> Waiting for funds. <="); - } - WalletFundedScreenStep::WaitingForAssetLock => { - ui.heading( - "=> Waiting for Core Chain to produce proof of transfer of funds. <=", - ); - } - WalletFundedScreenStep::WaitingForPlatformAcceptance => { - ui.heading("=> Waiting for Platform acknowledgement. <="); - } - WalletFundedScreenStep::Success => { - ui.heading("...Success..."); - } - _ => {} + match step { + WalletFundedScreenStep::WaitingOnFunds => { + ui.heading("=> Waiting for funds. <="); + } + WalletFundedScreenStep::WaitingForAssetLock => { + ui.heading( + "=> Waiting for Core Chain to produce proof of transfer of funds. <=", + ); + } + WalletFundedScreenStep::WaitingForPlatformAcceptance => { + ui.heading("=> Waiting for Platform acknowledgement. <="); + } + WalletFundedScreenStep::Success => { + ui.heading("...Success..."); } + _ => {} } AppAction::None }); diff --git a/src/ui/identities/top_up_identity_screen/mod.rs b/src/ui/identities/top_up_identity_screen/mod.rs index 0178ac738..19b63d588 100644 --- a/src/ui/identities/top_up_identity_screen/mod.rs +++ b/src/ui/identities/top_up_identity_screen/mod.rs @@ -13,6 +13,7 @@ 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; use crate::ui::components::info_popup::InfoPopup; @@ -24,7 +25,6 @@ use crate::ui::components::wallet_unlock_popup::{ }; use crate::ui::identities::add_new_identity_screen::FundingMethod; use crate::ui::identities::funding_common::WalletFundedScreenStep; -use crate::ui::theme::DashColors; use crate::ui::{MessageType, ScreenLike}; use dash_sdk::dashcore_rpc::dashcore::Address; use dash_sdk::dashcore_rpc::dashcore::transaction::special_transaction::TransactionPayload; @@ -54,7 +54,6 @@ pub struct TopUpIdentityScreen { funding_amount_input: Option, funding_utxo: Option<(OutPoint, TxOut, Address)>, copied_to_clipboard: Option>, - error_message: Option, wallet_unlock_popup: WalletUnlockPopup, show_pop_up_info: Option, pub app_context: Arc, @@ -80,7 +79,6 @@ impl TopUpIdentityScreen { funding_amount_input: None, funding_utxo: None, copied_to_clipboard: None, - error_message: None, wallet_unlock_popup: WalletUnlockPopup::new(), show_pop_up_info: None, app_context: app_context.clone(), @@ -428,9 +426,9 @@ impl TopUpIdentityScreen { } impl ScreenLike for TopUpIdentityScreen { - 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. if message_type == MessageType::Error { - self.error_message = Some(format!("Error topping up identity: {}", message)); // Reset step so UI is not stuck on waiting messages let mut step = self.step.write().unwrap(); if *step == WalletFundedScreenStep::WaitingForPlatformAcceptance @@ -438,8 +436,6 @@ impl ScreenLike for TopUpIdentityScreen { { *step = WalletFundedScreenStep::ReadyToCreate; } - } else { - self.error_message = Some(message.to_string()); } } fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { @@ -454,7 +450,6 @@ impl ScreenLike for TopUpIdentityScreen { self.funding_amount_exact = None; self.funding_amount_input = None; self.copied_to_clipboard = None; - self.error_message = None; let mut step = self.step.write().unwrap(); *step = WalletFundedScreenStep::Success; @@ -527,30 +522,6 @@ impl ScreenLike for TopUpIdentityScreen { action |= island_central_panel(ctx, |ui| { let mut inner_action = AppAction::None; - let _dark_mode = ui.ctx().style().visuals.dark_mode; - - // Display error message at the top, outside of scroll area - if let Some(error_message) = self.error_message.clone() { - let message_color = DashColors::ERROR; - - ui.horizontal(|ui| { - egui::Frame::new() - .fill(message_color.gamma_multiply(0.1)) - .inner_margin(egui::Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, message_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label(egui::RichText::new(&error_message).color(message_color)); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.error_message = None; - } - }); - }); - }); - ui.add_space(10.0); - } ScrollArea::vertical().show(ui, |ui| { let step = { *self.step.read().unwrap() }; @@ -640,7 +611,7 @@ impl ScreenLike for TopUpIdentityScreen { if let Some(wallet) = &self.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/tokens/mint_tokens_screen.rs b/src/ui/tokens/mint_tokens_screen.rs index 6a77fe0c0..3e96e83a1 100644 --- a/src/ui/tokens/mint_tokens_screen.rs +++ b/src/ui/tokens/mint_tokens_screen.rs @@ -7,6 +7,7 @@ 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}; @@ -48,7 +49,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; pub enum MintTokensStatus { NotStarted, WaitingForResult(u64), // Use seconds or millis - ErrorMessage(String), + Error, Complete, } @@ -68,7 +69,6 @@ pub struct MintTokensScreen { pub amount: Option, pub amount_input: Option, status: MintTokensStatus, - error_message: Option, /// Basic references pub app_context: Arc, @@ -100,7 +100,9 @@ impl MintTokensScreen { ) .cloned(); - let mut error_message = None; + let set_error_banner = |msg: &str| { + MessageBanner::set_global(app_context.egui_ctx(), msg, MessageType::Error); + }; let group = match identity_token_info .token_config @@ -108,32 +110,30 @@ impl MintTokensScreen { .authorized_to_make_change_action_takers() { AuthorizedActionTakers::NoOne => { - error_message = Some("Minting is not allowed on this token".to_string()); + set_error_banner("Minting 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 mint this token. Only the contract owner is." - .to_string(), + set_error_banner( + "You are not allowed to mint 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 mint this token".to_string()); + set_error_banner("You are not allowed to mint 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 } @@ -145,7 +145,7 @@ impl MintTokensScreen { { Ok(group) => Some((group_pos, group.clone())), Err(e) => { - error_message = Some(format!("Invalid contract: {}", e)); + set_error_banner(&format!("Invalid contract: {}", e)); None } } @@ -160,7 +160,7 @@ impl MintTokensScreen { { Ok(group) => Some((*group_pos, group.clone())), Err(e) => { - error_message = Some(format!("Invalid contract: {}", e)); + set_error_banner(&format!("Invalid contract: {}", e)); None } } @@ -183,12 +183,16 @@ 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 error_message, + &mut wallet_error, ); + if let Some(e) = wallet_error { + set_error_banner(&e); + } Self { identity_token_info, @@ -203,7 +207,6 @@ impl MintTokensScreen { amount: None, amount_input: None, status: MintTokensStatus::NotStarted, - error_message, app_context: app_context.clone(), confirmation_dialog: None, selected_wallet, @@ -224,7 +227,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::NotStarted | MintTokensStatus::ErrorMessage(_) => true, + MintTokensStatus::NotStarted | MintTokensStatus::Error => true, }; let response = ui.add_enabled_ui(enabled, |ui| amount_input.show(ui)).inner; @@ -279,8 +282,12 @@ impl MintTokensScreen { fn confirmation_ok(&mut self) -> AppAction { if self.amount.is_none() || self.amount == Some(Amount::new(0, 0)) { - self.status = MintTokensStatus::ErrorMessage("Invalid amount".into()); - self.error_message = Some("Invalid amount".into()); + self.status = MintTokensStatus::Error; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Invalid amount", + MessageType::Error, + ); return AppAction::None; } @@ -293,8 +300,12 @@ impl MintTokensScreen { ); if parsed_receiver_id.is_err() { - self.status = MintTokensStatus::ErrorMessage("Invalid receiver".into()); - self.error_message = Some("Invalid receiver".into()); + self.status = MintTokensStatus::Error; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Invalid receiver", + MessageType::Error, + ); return AppAction::None; } @@ -353,10 +364,10 @@ impl MintTokensScreen { } impl ScreenLike for MintTokensScreen { - 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. if let MessageType::Error = message_type { - self.status = MintTokensStatus::ErrorMessage(message.to_string()); - self.error_message = Some(message.to_string()); + self.status = MintTokensStatus::Error; } } @@ -487,7 +498,7 @@ impl ScreenLike for MintTokensScreen { // 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); @@ -704,11 +715,8 @@ impl ScreenLike for MintTokensScreen { let elapsed = now - start_time; ui.label(format!("Minting... elapsed: {} seconds", elapsed)); } - MintTokensStatus::ErrorMessage(msg) => { - ui.colored_label( - DashColors::error_color(dark_mode), - format!("Error: {}", msg), - ); + MintTokensStatus::Error => { + // Error display is handled by the global MessageBanner } MintTokensStatus::Complete => { // handled above diff --git a/src/ui/tokens/tokens_screen/mod.rs b/src/ui/tokens/tokens_screen/mod.rs index 6f6039a7a..5eee677c7 100644 --- a/src/ui/tokens/tokens_screen/mod.rs +++ b/src/ui/tokens/tokens_screen/mod.rs @@ -3248,6 +3248,7 @@ mod tests { None, Default::default(), Default::default(), + egui::Context::default(), ) .expect("Expected to create AppContext"); let mut token_creator_ui = TokensScreen::new(&app_context, TokensSubscreen::TokenCreator); @@ -3560,6 +3561,7 @@ mod tests { None, Default::default(), Default::default(), + egui::Context::default(), ) .expect("Expected to create AppContext"); let mut token_creator_ui = TokensScreen::new(&app_context, TokensSubscreen::TokenCreator); @@ -3686,6 +3688,7 @@ mod tests { None, Default::default(), Default::default(), + egui::Context::default(), ) .expect("Expected to create AppContext"); let mut token_creator_ui = TokensScreen::new(&app_context, TokensSubscreen::TokenCreator); diff --git a/src/ui/wallets/wallets_screen/dialogs.rs b/src/ui/wallets/wallets_screen/dialogs.rs index ccda62dd7..701fff7f7 100644 --- a/src/ui/wallets/wallets_screen/dialogs.rs +++ b/src/ui/wallets/wallets_screen/dialogs.rs @@ -15,6 +15,7 @@ use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; use dash_sdk::dpp::key_wallet::bip32::DerivationPath; use eframe::egui::{self, ComboBox, Context}; use eframe::epaint::TextureHandle; +use egui::Color32; use egui::load::SizedTexture; use egui::{Frame, Margin, RichText, TextureOptions}; use std::sync::{Arc, RwLock}; diff --git a/tests/kittest/main.rs b/tests/kittest/main.rs index 1562e7344..4a11e625f 100644 --- a/tests/kittest/main.rs +++ b/tests/kittest/main.rs @@ -1,5 +1,6 @@ mod create_asset_lock_screen; mod identities_screen; +mod message_banner; mod network_chooser; mod startup; mod wallets_screen; diff --git a/tests/kittest/message_banner.rs b/tests/kittest/message_banner.rs new file mode 100644 index 000000000..26db9c1d0 --- /dev/null +++ b/tests/kittest/message_banner.rs @@ -0,0 +1,443 @@ +use std::time::Duration; + +use dash_evo_tool::ui::MessageType; +use dash_evo_tool::ui::components::{BannerStatus, Component, ComponentResponse, MessageBanner}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; + +/// Test that show_global renders nothing and does not panic when no message is set. +#[test] +fn test_banner_renders_nothing_when_empty() { + let mut harness = Harness::builder() + .with_size(egui::vec2(400.0, 200.0)) + .build_ui(|ui| { + MessageBanner::show_global(ui); + }); + harness.run(); + // No labels should be present (empty banner renders nothing) + assert!(harness.query_by_label("x").is_none()); +} + +/// Test the global set/has/clear cycle using a standalone egui::Context. +#[test] +fn test_global_set_and_has() { + let ctx = egui::Context::default(); + + // Initially no global message + assert!(!MessageBanner::has_global(&ctx)); + + // Set a global error message + let handle = MessageBanner::set_global(&ctx, "Something went wrong", MessageType::Error); + assert!(MessageBanner::has_global(&ctx)); + assert!(handle.elapsed().is_some()); + + // Clear specific message + MessageBanner::clear_global_message(&ctx, "Something went wrong"); + assert!(!MessageBanner::has_global(&ctx)); + + // Handle should now report None for elapsed (banner gone) + assert!(handle.elapsed().is_none()); +} + +/// Test the per-instance set_message / has_message / clear cycle. +#[test] +fn test_instance_set_and_has() { + let mut banner = MessageBanner::new(); + + assert!(!banner.has_message()); + assert_eq!(banner.current_value(), None); + + banner.set_message("Error occurred", MessageType::Error); + assert!(banner.has_message()); + assert_eq!(banner.current_value(), Some(BannerStatus::Visible)); + + banner.clear(); + assert!(!banner.has_message()); + assert_eq!(banner.current_value(), None); +} + +/// Test that a global error message renders and the text appears. +#[test] +fn test_banner_renders_error_message() { + let message_text = "Critical failure detected"; + + let mut harness = Harness::builder() + .with_size(egui::vec2(600.0, 200.0)) + .build_ui(|ui| { + MessageBanner::set_global(ui.ctx(), message_text, MessageType::Error); + MessageBanner::show_global(ui); + }); + harness.run(); + // The message text and dismiss button should be present + assert!(harness.query_by_label(message_text).is_some()); + assert!(harness.query_by_label("x").is_some()); + // Error banners have no auto-dismiss countdown + assert!(harness.query_by_label_contains("s)").is_none()); +} + +/// Test that all four MessageType variants render with correct text and icon. +#[test] +fn test_banner_renders_all_types() { + let variants = [ + (MessageType::Error, "Error message", "\u{26A0}"), + (MessageType::Warning, "Warning message", "\u{26A0}"), + (MessageType::Success, "Success message", "\u{2713}"), + (MessageType::Info, "Info message", "\u{2139}"), + ]; + + for (msg_type, text, icon) in variants { + let mut harness = Harness::builder() + .with_size(egui::vec2(600.0, 200.0)) + .build_ui(move |ui| { + MessageBanner::set_global(ui.ctx(), text, msg_type); + MessageBanner::show_global(ui); + }); + harness.run(); + // Message text, icon, and dismiss button should all be present + assert!(harness.query_by_label(text).is_some(), "Missing text for {:?}", msg_type); + assert!(harness.query_by_label(icon).is_some(), "Missing icon for {:?}", msg_type); + assert!(harness.query_by_label("x").is_some(), "Missing dismiss button for {:?}", msg_type); + } +} + +/// Test that multiple different messages coexist (not overwritten). +#[test] +fn test_multiple_messages_coexist() { + let ctx = egui::Context::default(); + + let handle1 = MessageBanner::set_global(&ctx, "Error one", MessageType::Error); + let handle2 = MessageBanner::set_global(&ctx, "Error two", MessageType::Error); + assert!(MessageBanner::has_global(&ctx)); + assert!(handle1.elapsed().is_some()); + assert!(handle2.elapsed().is_some()); + + // Clear first — second should still exist + MessageBanner::clear_global_message(&ctx, "Error one"); + assert!(MessageBanner::has_global(&ctx)); + assert!(handle1.elapsed().is_none()); + assert!(handle2.elapsed().is_some()); + + // Clear second — nothing left + MessageBanner::clear_global_message(&ctx, "Error two"); + assert!(!MessageBanner::has_global(&ctx)); + assert!(handle2.elapsed().is_none()); +} + +/// Test that duplicate text is deduplicated (idempotent set_global). +#[test] +fn test_deduplication() { + let ctx = egui::Context::default(); + + let handle1 = MessageBanner::set_global(&ctx, "Same message", MessageType::Error); + let handle2 = MessageBanner::set_global(&ctx, "Same message", MessageType::Error); + let handle3 = MessageBanner::set_global(&ctx, "Same message", MessageType::Error); + + // All handles should reference the same banner (all alive) + assert!(handle1.elapsed().is_some()); + assert!(handle2.elapsed().is_some()); + assert!(handle3.elapsed().is_some()); + + // Only one should exist — clear_global_message removes it, then nothing left + MessageBanner::clear_global_message(&ctx, "Same message"); + assert!(!MessageBanner::has_global(&ctx)); + + // All handles should now be dead + assert!(handle1.elapsed().is_none()); + assert!(handle2.elapsed().is_none()); + assert!(handle3.elapsed().is_none()); +} + +/// Test replace_global finds and replaces an existing message. +#[test] +fn test_replace_global_finds_and_replaces() { + let ctx = egui::Context::default(); + + let original_handle = + MessageBanner::set_global(&ctx, "Generic success", MessageType::Success); + MessageBanner::set_global(&ctx, "An error", MessageType::Error); + + // Replace the generic one + let replaced_handle = MessageBanner::replace_global( + &ctx, + "Generic success", + "Specific success", + MessageType::Success, + ); + + // Both handles should point to the same banner (same key, text changed) + assert!(original_handle.elapsed().is_some()); + assert!(replaced_handle.elapsed().is_some()); + + // The old text should be gone, new text should exist + // Clearing "Generic success" should be a no-op (already replaced) + MessageBanner::clear_global_message(&ctx, "Generic success"); + // "Specific success" and "An error" should still be present + assert!(MessageBanner::has_global(&ctx)); + + // Clear the remaining two + MessageBanner::clear_global_message(&ctx, "Specific success"); + MessageBanner::clear_global_message(&ctx, "An error"); + assert!(!MessageBanner::has_global(&ctx)); +} + +/// Test replace_global with unknown old_text adds as new. +#[test] +fn test_replace_global_adds_when_not_found() { + let ctx = egui::Context::default(); + + let handle = + MessageBanner::replace_global(&ctx, "nonexistent", "New message", MessageType::Info); + assert!(MessageBanner::has_global(&ctx)); + assert!(handle.elapsed().is_some()); + + MessageBanner::clear_global_message(&ctx, "New message"); + assert!(!MessageBanner::has_global(&ctx)); + assert!(handle.elapsed().is_none()); +} + +/// Test clear_global_message removes only the specific message. +#[test] +fn test_clear_global_message_removes_specific() { + let ctx = egui::Context::default(); + + let keep_handle = MessageBanner::set_global(&ctx, "Keep this", MessageType::Error); + let remove_handle = MessageBanner::set_global(&ctx, "Remove this", MessageType::Warning); + + MessageBanner::clear_global_message(&ctx, "Remove this"); + assert!(MessageBanner::has_global(&ctx)); + assert!(keep_handle.elapsed().is_some()); + assert!(remove_handle.elapsed().is_none()); + + MessageBanner::clear_global_message(&ctx, "Keep this"); + assert!(!MessageBanner::has_global(&ctx)); + assert!(keep_handle.elapsed().is_none()); +} + +/// Test that empty string is a no-op for set_global (does not clear). +#[test] +fn test_set_empty_string_is_noop() { + let ctx = egui::Context::default(); + + MessageBanner::set_global(&ctx, "Existing", MessageType::Info); + let empty_handle = MessageBanner::set_global(&ctx, "", MessageType::Info); + // Empty string should not clear existing messages + assert!(MessageBanner::has_global(&ctx)); + // Empty handle should not reference any real banner + assert!(empty_handle.elapsed().is_none()); +} + +/// Test that per-instance set_message with empty string clears. +#[test] +fn test_instance_set_empty_string_clears() { + let mut banner = MessageBanner::new(); + banner.set_message("Some message", MessageType::Warning); + assert!(banner.has_message()); + assert_eq!(banner.current_value(), Some(BannerStatus::Visible)); + + banner.set_message("", MessageType::Warning); + assert!(!banner.has_message()); + assert_eq!(banner.current_value(), None); +} + +/// Test that a per-instance banner renders via Component::show() and returns Visible status. +#[test] +fn test_instance_banner_rendering() { + let mut banner = MessageBanner::new(); + banner.set_message("Instance error", MessageType::Error); + + let mut harness = Harness::builder() + .with_size(egui::vec2(600.0, 200.0)) + .build_ui(move |ui| { + let response = banner.show(ui); + assert_eq!(response.inner.status, Some(BannerStatus::Visible)); + assert!(!response.inner.has_changed()); + assert!(response.inner.is_valid()); + assert!(response.inner.error_message().is_none()); + }); + harness.run(); + assert!(harness.query_by_label("Instance error").is_some()); + assert!(harness.query_by_label("x").is_some()); +} + +/// Test that Component::show() returns None status when no message is set. +#[test] +fn test_instance_banner_empty_status() { + let mut banner = MessageBanner::new(); + + let mut harness = Harness::builder() + .with_size(egui::vec2(600.0, 200.0)) + .build_ui(move |ui| { + let response = banner.show(ui); + assert_eq!(response.inner.status, None); + assert!(!response.inner.has_changed()); + assert!(response.inner.is_valid()); + assert!(response.inner.error_message().is_none()); + }); + harness.run(); + // Empty banner renders nothing + assert!(harness.query_by_label("x").is_none()); +} + +/// Test that current_value() returns Visible when message is set, None otherwise. +#[test] +fn test_instance_current_value() { + let mut banner = MessageBanner::new(); + assert_eq!(banner.current_value(), None); + + banner.set_message("Some error", MessageType::Error); + assert_eq!(banner.current_value(), Some(BannerStatus::Visible)); + + banner.clear(); + assert_eq!(banner.current_value(), None); +} + +/// Test that multiple banners render and all texts appear. +#[test] +fn test_multiple_banners_render() { + let mut harness = Harness::builder() + .with_size(egui::vec2(600.0, 400.0)) + .build_ui(|ui| { + MessageBanner::set_global(ui.ctx(), "Error one", MessageType::Error); + MessageBanner::set_global(ui.ctx(), "Warning one", MessageType::Warning); + MessageBanner::set_global(ui.ctx(), "Success one", MessageType::Success); + assert!(MessageBanner::has_global(ui.ctx())); + MessageBanner::show_global(ui); + }); + harness.run(); + assert!(harness.query_by_label("Error one").is_some()); + assert!(harness.query_by_label("Warning one").is_some()); + assert!(harness.query_by_label("Success one").is_some()); + // Each banner has its own dismiss button + assert_eq!(harness.get_all_by_label("x").count(), 3); +} + +/// Test BannerHandle::clear() removes the banner. +#[test] +fn test_handle_clear() { + let ctx = egui::Context::default(); + + let handle = MessageBanner::set_global(&ctx, "To be cleared", MessageType::Info); + assert!(MessageBanner::has_global(&ctx)); + + handle.clear(); + assert!(!MessageBanner::has_global(&ctx)); +} + +/// Test BannerHandle::set_message() updates the banner text. +#[test] +fn test_handle_set_message() { + let ctx = egui::Context::default(); + + let handle = MessageBanner::set_global(&ctx, "Original text", MessageType::Info); + assert!(handle.set_message("Updated text").is_some()); + + // Old text should not match anymore + MessageBanner::clear_global_message(&ctx, "Original text"); + // Banner should still exist under the new text + assert!(MessageBanner::has_global(&ctx)); + assert!(handle.elapsed().is_some()); + + // Clearing the new text should remove it + MessageBanner::clear_global_message(&ctx, "Updated text"); + assert!(!MessageBanner::has_global(&ctx)); +} + +/// Test BannerHandle::set_message() returns None on a cleared banner. +#[test] +fn test_handle_set_message_on_cleared_banner() { + let ctx = egui::Context::default(); + + let handle = MessageBanner::set_global(&ctx, "Will be cleared", MessageType::Info); + MessageBanner::clear_global_message(&ctx, "Will be cleared"); + + assert!(handle.set_message("New text").is_none()); + // No banner should have been created + assert!(!MessageBanner::has_global(&ctx)); +} + +/// Test BannerHandle::with_auto_dismiss() returns None on a cleared banner. +#[test] +fn test_handle_with_auto_dismiss_on_cleared_banner() { + let ctx = egui::Context::default(); + + let handle = MessageBanner::set_global(&ctx, "Temporary", MessageType::Info); + MessageBanner::clear_global_message(&ctx, "Temporary"); + + assert!(handle.with_auto_dismiss(Duration::from_secs(10)).is_none()); +} + +/// Test BannerHandle::with_elapsed() returns None on a cleared banner. +#[test] +fn test_handle_with_elapsed_on_cleared_banner() { + let ctx = egui::Context::default(); + + let handle = MessageBanner::set_global(&ctx, "Gone", MessageType::Info); + MessageBanner::clear_global_message(&ctx, "Gone"); + + assert!(handle.with_elapsed().is_none()); +} + +/// Test BannerHandle::with_elapsed() returns Some on a live banner. +#[test] +fn test_handle_with_elapsed_on_live_banner() { + let ctx = egui::Context::default(); + + let handle = MessageBanner::set_global(&ctx, "Loading...", MessageType::Info); + assert!(handle.with_elapsed().is_some()); + assert!(handle.elapsed().is_some()); +} + +/// Test that handle.clear() only removes the specific banner, not others. +#[test] +fn test_handle_clear_leaves_other_banners() { + let ctx = egui::Context::default(); + + let handle1 = MessageBanner::set_global(&ctx, "Banner one", MessageType::Error); + let handle2 = MessageBanner::set_global(&ctx, "Banner two", MessageType::Warning); + + handle1.clear(); + assert!(MessageBanner::has_global(&ctx)); + assert!(handle2.elapsed().is_some()); + + handle2.clear(); + assert!(!MessageBanner::has_global(&ctx)); +} + +/// Test per-instance set_auto_dismiss builder chaining. +#[test] +fn test_instance_set_auto_dismiss() { + let mut banner = MessageBanner::new(); + banner + .set_message("Timed message", MessageType::Error) + .set_auto_dismiss(Duration::from_secs(10)); + assert!(banner.has_message()); + assert_eq!(banner.current_value(), Some(BannerStatus::Visible)); +} + +/// Test that replace_global with empty new_text clears the old message. +#[test] +fn test_replace_global_empty_new_text_clears() { + let ctx = egui::Context::default(); + + MessageBanner::set_global(&ctx, "Old message", MessageType::Info); + assert!(MessageBanner::has_global(&ctx)); + + let handle = MessageBanner::replace_global(&ctx, "Old message", "", MessageType::Info); + assert!(!MessageBanner::has_global(&ctx)); + // Handle for empty replacement should not reference a real banner + assert!(handle.elapsed().is_none()); +} + +/// Test BannerHandle::with_auto_dismiss() returns Some on a live banner. +#[test] +fn test_handle_with_auto_dismiss_on_live_banner() { + let ctx = egui::Context::default(); + + let handle = MessageBanner::set_global(&ctx, "Dismissable", MessageType::Error); + assert!( + handle + .with_auto_dismiss(Duration::from_secs(30)) + .is_some() + ); + assert!(handle.elapsed().is_some()); +} From 0dbed4a34552a5d0fa4ff8f6fd0ab282ac214f63 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 18 Feb 2026 16:51:36 +0100 Subject: [PATCH 04/46] chore: docs --- .../architecture.md | 889 +++--------------- .../message-banner-ux-spec.md | 266 ++---- 2 files changed, 230 insertions(+), 925 deletions(-) diff --git a/docs/ai-design/2026-02-17-unified-messages/architecture.md b/docs/ai-design/2026-02-17-unified-messages/architecture.md index b51d5af16..9b031a8b1 100644 --- a/docs/ai-design/2026-02-17-unified-messages/architecture.md +++ b/docs/ai-design/2026-02-17-unified-messages/architecture.md @@ -1,834 +1,247 @@ # MessageBanner Component -- Technical Architecture -## 0. Current State Analysis - -### Overview - -The codebase has **~50 screens** (structs implementing `ScreenLike`) with no unified message rendering component. There are **4 distinct rendering patterns**, **8+ different error colors**, and **two competing `MessageType` enums**. Each screen independently manages its own message fields and rendering. - -### Rendering Style Taxonomy - -| Pattern | Description | Used By | -|---------|-------------|---------| -| **A - Framed Banner** | `Frame` with `fill(color.gamma_multiply(0.1))`, border stroke, RichText label, "Dismiss" button | ~8 screens | -| **B - Timed Badge** | `(String, MessageType, DateTime)` tuple, rendered with countdown timer (auto-dismiss ~10s) | ~8 screens | -| **C - Status Enum** | Error stored in a status enum variant (e.g., `TransferCreditsStatus::ErrorMessage`), rendered inline via `colored_label` | ~8 screens | -| **D - Bare colored_label** | Simple `ui.colored_label(Color32::X, message)` inline, no frame, no dismiss | ~20+ screens | - -### Error Color Inconsistency - -| Color Value | Usage Count | Screens | -|---|---|---| -| `Color32::from_rgb(255, 100, 100)` | ~8 screens | AddNewIdentityScreen, TopUpIdentityScreen, WalletsBalancesScreen, WalletSendScreen, PlatformInfoScreen, AddressBalanceScreen, KeyInfoScreen, ImportMnemonicScreen | -| `Color32::DARK_RED` | ~10 screens | WithdrawalScreen, TransferScreen, RegisterDpnsNameScreen, IdentitiesScreen, DPNSScreen, ContactsList, ContactRequests, AddContactScreen, SendPaymentScreen, SetTokenPriceScreen | -| `Color32::RED` | ~5 screens | FreezeTokensScreen, UnfreezeTokensScreen, PauseTokensScreen, ResumeTokensScreen, ClaimTokensScreen | -| `DashColors::error_color(dark_mode)` | ~7 screens | MintTokensScreen, BurnTokensScreen, DestroyFrozenFundsScreen, ProfileSearchScreen, ContactProfileViewerScreen, QRCodeGeneratorScreen, QRScannerScreen | -| `DashColors::ERROR` (`from_rgb(235,87,87)`) | ~1 screen | ContactInfoEditorScreen | -| `Color32::from_rgb(220, 80, 80)` | ~1 screen | WalletUnlockPopup | - -### Per-Screen Catalog - -#### Identities - -| Screen | File | Message Fields | Rendering | `display_message` | `display_task_result` | Quirks | -|---|---|---|---|---|---|---| -| `IdentitiesScreen` | `identities/identities_screen.rs` | `backend_message: Option<(String, MessageType, DateTime)>` | Pattern B (timed, 10s auto-dismiss with countdown) | Custom: sets `backend_message` | Custom: `RefreshedIdentity` → Success | Shows spinner during refresh; Error=`DARK_RED`, Success=`DARK_GREEN` | -| `AddNewIdentityScreen` | `identities/add_new_identity_screen/mod.rs` | `error_message: Option` | Pattern A (framed banner + Dismiss) | Custom: sets `error_message` | Default | Prefix: `"Error registering identity: {}"`. Color: `from_rgb(255,100,100)` | -| `AddExistingIdentityScreen` | `identities/add_existing_identity_screen.rs` | `error_message`, `backend_message`, `success_message` | Pattern A for errors | Custom: routes to 3 fields | Custom: `LoadedIdentity`, `Message` | **3 separate message fields**; `backend_message` = progress indicator | -| `TopUpIdentityScreen` | `identities/top_up_identity_screen/mod.rs` | `error_message: Option` | Pattern A (framed banner + Dismiss) | Custom: sets `error_message`; resets step state on Error | Default | Also resets `WalletFundedScreenStep` on error | -| `WithdrawalScreen` | `identities/withdraw_screen.rs` | `error_message`, `withdraw_from_identity_status` enum | Pattern C (status enum) | Custom: sets status enum | Custom: `WithdrewFromIdentity` → Complete | Status enum is primary error carrier | -| `TransferScreen` | `identities/transfer_screen.rs` | `error_message`, `transfer_credits_status` enum | Pattern C (status enum) | Custom: sets **both** enum AND `error_message` | Custom: `TransferredCredits` → Complete | **Redundant dual-field storage** | -| `RegisterDpnsNameScreen` | `identities/register_dpns_name_screen.rs` | `error_message: Option` | Pattern D (bare colored_label) | Custom: sets `error_message` | Default | Color: `DARK_RED` | - -#### Identity Keys - -| Screen | File | Message Fields | Rendering | `display_message` | `display_task_result` | Quirks | -|---|---|---|---|---|---|---| -| `KeysScreen` | `identities/keys/keys_screen.rs` | None | None | Default no-op | Default | Minimal screen, no messages | -| `KeyInfoScreen` | `identities/keys/key_info_screen.rs` | `error_message`, `sign_error_message` | Pattern A-like for `error_message`; Pattern D for `sign_error_message` | Default no-op | Default | **2 error fields** for different operations; no `display_message` override | -| `AddKeyScreen` | `identities/keys/add_key_screen.rs` | `error_message`, `add_key_status` enum | Pattern C (status enum) | Custom: sets `add_key_status` | Custom: `AddedKeyToIdentity` → Complete | | - -#### DPNS - -| Screen | File | Message Fields | Rendering | `display_message` | `display_task_result` | Quirks | -|---|---|---|---|---|---|---| -| `DPNSScreen` | `dpns/dpns_contested_names_screen.rs` | `message: Option<(String, MessageType, DateTime)>`, `bulk_schedule_message` | Pattern B (timed) + bulk block | Custom: sets `message` | Custom: extensive vote handling | **2 message slots**; bulk message uses emoji icons (❌/🎉) | - -#### Wallets - -| Screen | File | Message Fields | Rendering | `display_message` | `display_task_result` | Quirks | -|---|---|---|---|---|---|---| -| `WalletsBalancesScreen` | `wallets/wallets_screen/mod.rs` | `message: Option<(String, MessageType, DateTime)>`, `sk_error_message` | Pattern A (framed banner + Dismiss) | Custom: routes errors to fund dialog if processing | Custom: wallet ops | **Dialog interception**: fund-platform dialog captures errors | -| `AddNewWalletScreen` | `wallets/add_new_wallet_screen.rs` | `error: Option` | **Modal Window** (`egui::Window::new("Error")`) | Default no-op | Default | Only screen using modal error popup | -| `ImportMnemonicScreen` | `wallets/import_mnemonic_screen.rs` | `error: Option` | Pattern D (bare colored_label) | Default no-op | Default | No `display_message` override | -| `WalletSendScreen` | `wallets/send_screen.rs` | `send_status: SendStatus`, `error_message` | Pattern A (framed banner) + full success screen | Custom: sets `send_status` enum | Custom: `WalletPayment`, etc. | Status enum drives display; success shows full-page 🎉 view | -| `SingleKeyWalletSendScreen` | `wallets/single_key_send_screen.rs` | `message: Option<(String, MessageType, DateTime)>`, `error_message` | Pattern B (timed banner) | Custom: sets `message` | Custom: `WalletPayment` | 2 fields | -| `CreateAssetLockScreen` | `wallets/create_asset_lock_screen.rs` | `message: Option<(String, MessageType, DateTime)>`, `error_message` | Pattern B (timed banner) | Custom: sets `message` | Custom: asset lock types | Helper methods `set_error_message()`/`error_message()` | -| `AssetLockDetailScreen` | `wallets/asset_lock_detail_screen.rs` | `message: Option<(String, MessageType, DateTime)>`, `error_message` | Pattern B (timed banner) | Custom: sets `message` | Custom: asset lock retrieval | Helper methods | - -#### Contracts / Documents - -| Screen | File | Message Fields | Rendering | `display_message` | `display_task_result` | Quirks | -|---|---|---|---|---|---|---| -| `DocumentQueryScreen` | `contracts_documents/contracts_documents_screen.rs` | `error_message: Option<(String, MessageType, DateTime)>` | Pattern D (colored_label + timestamp expiry) | Custom: **only handles** `"Error fetching documents"` | Custom: Documents/PageDocuments | **Filters messages by text content** | -| `AddContractsScreen` | `contracts_documents/add_contracts_screen.rs` | `add_contracts_status` enum | Pattern C (status enum) | Custom: sets status enum | Custom: `FetchedContracts` | | -| `RegisterDataContractScreen` | `contracts_documents/register_contract_screen.rs` | `error_message`, `broadcast_status` enum | Pattern C + Pattern A | Custom: routes by text pattern | Custom: `RegisteredContract` | **Text-pattern branch** for proof error special case | -| `UpdateDataContractScreen` | `contracts_documents/update_contract_screen.rs` | `error_message`, `broadcast_status` enum | Pattern C + inline | Custom: same as Register | Custom: `UpdatedContract` | Same pattern as Register | -| `DocumentActionScreen` | `contracts_documents/document_action_screen.rs` | `backend_message: Option` | Not rendered via banner | Custom: sets `backend_message`; **ignores message_type** | Custom: Broadcasted/Deleted | `_message_type` — type parameter unused | -| `GroupActionsScreen` | `contracts_documents/group_actions_screen.rs` | `fetch_group_actions_status` enum | Pattern C (status enum) | Custom: sets status enum | Custom: `ActiveGroupActions` | | - -#### Tokens - -| Screen | File | Message Fields | Rendering | `display_message` | `display_task_result` | Quirks | -|---|---|---|---|---|---|---| -| `TokensScreen` | `tokens/tokens_screen/mod.rs` | `backend_message: Option<(String, MessageType, DateTime)>`, `token_creator_error_message` | Pattern B (timed banner) | Custom: smart routing (creator subpanel vs main) | Custom: extensive | **2 fields**; smart dispatch based on state | -| `MintTokensScreen` | `tokens/mint_tokens_screen.rs` | `error_message: Option` | Pattern D (colored_label) | Custom: Error → `error_message` | Default | Color: `DashColors::error_color(dark)` | -| `BurnTokensScreen` | `tokens/burn_tokens_screen.rs` | `error_message: Option` | Pattern D | Custom: Error → `error_message` | Default | Color: `DashColors::error_color(dark)` | -| `TransferTokensScreen` | `tokens/transfer_tokens_screen.rs` | `transfer_tokens_status` enum | Pattern C (status enum) | Custom: sets status enum | Custom: `TransferredTokens` | No `error_message` field | -| `FreezeTokensScreen` | `tokens/freeze_tokens_screen.rs` | `error_message: Option` | Pattern D | Custom: Error → `error_message` | Default | **Color: `Color32::RED`** (not DARK_RED) | -| `UnfreezeTokensScreen` | `tokens/unfreeze_tokens_screen.rs` | `error_message: Option` | Pattern D | Custom: Error → `error_message` | Default | Color: `Color32::RED` | -| `PauseTokensScreen` | `tokens/pause_tokens_screen.rs` | `error_message: Option` | Pattern D | Custom: Error → `error_message` | Default | Color: `Color32::RED` | -| `ResumeTokensScreen` | `tokens/resume_tokens_screen.rs` | `error_message: Option` | Pattern D | Custom: Error → `error_message` | Default | Color: `Color32::RED` | -| `DestroyFrozenFundsScreen` | `tokens/destroy_frozen_funds_screen.rs` | `error_message: Option` | Pattern D | Custom: Error → `error_message` | Default | Color: `DashColors::error_color(dark)` | -| `ClaimTokensScreen` | `tokens/claim_tokens_screen.rs` | `error_message: Option` | Pattern D | Custom: Error → `error_message` | Default | Color: `Color32::RED` | -| `ViewTokenClaimsScreen` | `tokens/view_token_claims_screen.rs` | `message: Option<(String, MessageType, DateTime)>` | Pattern D | Custom: all types → `message` | Custom: calls display_message if empty | | -| `AddTokenByIdScreen` | `tokens/add_token_by_id_screen.rs` | `error_message: Option` | Pattern D | Custom: Success = refresh; Error = `error_message` | (inline) | `display_message` handles Success as task result | -| `PurchaseTokenScreen` | `tokens/direct_token_purchase_screen.rs` | `error_message: Option` | Pattern D | Custom: Error → `error_message` | Default | | -| `SetTokenPriceScreen` | `tokens/set_token_price_screen.rs` | `error_message: Option` | Pattern D | Custom: Error → `error_message` | Default | Color: `DARK_RED`; also has amber validation labels | -| `UpdateTokenConfigScreen` | `tokens/update_token_config.rs` | `backend_message: Option<(String, MessageType, DateTime)>`, `error_message` (unused) | Pattern B (timed banner) | Custom: Error/Info → `backend_message` | Custom: `UpdatedTokenConfig` | **`error_message` field is explicitly commented `// unused`** | - -#### Tools - -| Screen | File | Message Fields | Rendering | `display_message` | `display_task_result` | Quirks | -|---|---|---|---|---|---|---| -| `PlatformInfoScreen` | `tools/platform_info_screen.rs` | `error_message: Option` | Pattern A (framed banner + Dismiss) | Custom: Error → `error_message` | Custom: PlatformInfo results | **Error blocks result display** (early return) | -| `GroveSTARKScreen` | `tools/grovestark_screen.rs` | `gen_error_message`, `verify_error_message` | Pattern D (with Dismiss in framed box) | Custom: routes to gen/verify based on mode | Custom: GeneratedProof, VerifiedProof | **2 mode-specific error fields** | -| `MasternodeListDiffScreen` | `tools/masternode_list_diff_screen.rs` | `ui_state.message: Option<(String, MessageType)>`, `ui_state.error` | Pattern A (framed banner) | Custom: Error → `ui_state.error`; Info → **silently discarded** | Custom: CoreItem, extensive | **Info messages explicitly dropped** | -| `ProofLogScreen` | `tools/proof_log_screen.rs` | None | None | Explicit no-op | Default | **Fully ignores all messages** | -| `ProofVisualizerScreen` | `tools/proof_visualizer_screen.rs` | None | None | Explicit no-op | Default | **Fully ignores all messages** | -| `TransitionVisualizerScreen` | `tools/transition_visualizer_screen.rs` | `broadcast_status` enum, `contract_fetch_message: Option<(String, Instant)>` | Pattern C (status enum) | Custom: routes by type | Custom: FetchedContract | `contract_fetch_message` is time-based | -| `AddressBalanceScreen` | `tools/address_balance_screen.rs` | `error_message: Option` | Pattern A (framed banner + Dismiss) | Custom: Error → `error_message` | Custom: AddressBalance | Color: `from_rgb(255,100,100)` | - -#### DashPay - -| Screen | File | Message Fields | Rendering | `display_message` | `display_task_result` | Quirks | -|---|---|---|---|---|---|---| -| `DashPayScreen` | `dashpay/dashpay_screen.rs` | None (delegates) | Delegates to active sub-screen | Custom: dispatches to sub-screens | Default | Pure delegator | -| `ContactsList` | `dashpay/contacts_list.rs` | `message: Option<(String, MessageType)>` | Pattern D | Custom: sets `message` | Custom | Color: `DARK_RED`/`DARK_GREEN`/`LIGHT_BLUE` | -| `ContactRequests` | `dashpay/contact_requests.rs` | `message: Option<(String, MessageType)>` | Pattern D (bold for errors) | Custom: sets `message` | Custom | | -| `ContactDetailsScreen` | `dashpay/contact_details.rs` | `message: Option<(String, MessageType)>` | Pattern D | **Public + trait impl** (trait delegates to pub fn) | Custom | | -| `ContactInfoEditorScreen` | `dashpay/contact_info_editor.rs` | `message: Option<(String, MessageType)>` | Pattern D | **Public + trait impl** | Custom | Uses `DashColors::SUCCESS/ERROR/INFO` constants | -| `ProfileSearchScreen` | `dashpay/profile_search.rs` | `message: Option<(String, MessageType)>` | Pattern D | **Public + trait impl** | Custom | Color: `DashColors::error_color(dark)` | -| `ContactProfileViewerScreen` | `dashpay/contact_profile_viewer.rs` | `message: Option<(String, MessageType)>` | Pattern D | **Public + trait impl** | Custom | | -| `AddContactScreen` | `dashpay/add_contact_screen.rs` | `message: Option<(String, MessageType)>` | Pattern D | Custom: wraps `AddContactError` | Custom | | -| `QRCodeGeneratorScreen` | `dashpay/qr_code_generator.rs` | `message: Option<(String, MessageType)>` | Pattern D | **Public + trait impl** | Default | | -| `QRScannerScreen` | `dashpay/qr_scanner.rs` | `message: Option<(String, MessageType)>` | Pattern D | **Public + trait impl** | Custom | | -| `SendPaymentScreen` | `dashpay/send_payment.rs` | `message: Option<(String, MessageType)>` | Pattern D | Custom: sets `sending=false` + `message` | Custom: `DashPayPaymentSent` | | - -#### Network / Other - -| Screen | File | Message Fields | Rendering | `display_message` | `display_task_result` | Quirks | -|---|---|---|---|---|---|---| -| `NetworkChooserScreen` | `network_chooser_screen.rs` | `custom_dash_qt_error_message`, `spv_clear_message`, `db_clear_message` | Inline specialized fields | Default no-op | Default | **3 specialized local message fields**; no standard message pipeline | - -### Key Issues Summary - -1. **No unified rendering component** — 4 distinct visual patterns with no shared helper -2. **8+ different error colors** — ranges from `Color32::RED` to `Color32::DARK_RED` to `from_rgb(255,100,100)` to `DashColors::error_color(dark)` -3. **Screens silently ignoring messages** — `KeysScreen`, `ProofLogScreen`, `ProofVisualizerScreen` (no-op), `MasternodeListDiffScreen` (drops Info), `DocumentActionScreen` (ignores type) -4. **Redundant dual-field storage** — `TransferScreen` stores errors in both status enum and `error_message` -5. **Dead code** — `UpdateTokenConfigScreen.error_message` explicitly marked unused; `theme.rs::MessageType` is dead code -6. **6 screens with public+trait dual display_message** — DashPay sub-screens have both a `pub fn` and `ScreenLike` impl -7. **Inconsistent auto-dismiss** — 8 screens use `DateTime` timestamps for timed messages; the rest persist until user action -8. **Success messages often silently lost** — Token action screens (Mint, Burn, Freeze, etc.) use default `display_task_result` which calls `display_message("Success", Success)`, but their `display_message` only handles Error +## 1. Overview ---- - -## 1. MessageType Enum Unification - -### Current State - -Two competing `MessageType` enums exist: - -1. **`src/ui/mod.rs:830`** -- Active, 3 variants (Success, Info, Error), used by `ScreenLike` trait -2. **`src/ui/theme.rs:638`** -- Dead code (`#[allow(dead_code)]`), 4 variants (Success, Error, Warning, Info), has `color()` and `background_color()` methods +`MessageBanner` is a unified message display component that replaces the ~50 screens' ad-hoc `error_message`, `backend_message`, and `message` fields with a single consistent banner system. It operates in two modes: -### Decision +- **Global**: Multiple banners stored in egui context data, rendered centrally by `island_central_panel()`. Used for backend task results and screen-level messages. +- **Per-instance**: A `MessageBanner` struct owned by a screen, implementing the `Component` trait. Available for screen-local use cases. -**Extend** the active enum in `mod.rs` by adding a `Warning` variant. **Consolidate** the color methods from the dead-code `theme.rs::MessageType` into `DashColors` as dark-mode-aware methods (the dead enum only had light-mode colors). Then **delete** the dead-code enum in `theme.rs` (lines 636-664). +Both modes share the same rendering function (`render_banner()`), ensuring visual consistency. -### Unified Enum (in `src/ui/mod.rs`) +**File**: `src/ui/components/message_banner.rs` -```rust -#[derive(Debug, PartialEq, Clone, Copy)] -pub enum MessageType { - Success, - Info, - Warning, - Error, -} -``` +--- -This is the only change to the existing `MessageType`. The variant order follows severity (lowest to highest). Adding `Warning` is backward-compatible -- existing match arms that use `Success`, `Info`, `Error` will get a compiler error only in exhaustive matches, which is desirable to ensure all sites handle the new variant. +## 2. Data Structures -### New DashColors Methods (in `src/ui/theme.rs`) +### BannerState (private) -Add `info_color(dark_mode)` and `message_background_color(message_type, dark_mode)` to `DashColors`, consolidating all message-type-aware colors in one place: +Internal state for a single banner message: ```rust -impl DashColors { - /// Info severity color -- complements existing error_color/success_color/warning_color. - pub fn info_color(dark_mode: bool) -> Color32 { - if dark_mode { - Color32::from_rgb(100, 180, 255) // Lighter blue for dark mode - } else { - Self::DEEP_BLUE // Dark blue for light mode - } - } - - /// Returns the tinted background color for a message severity level. - /// Uses low alpha (8% light, 12% dark) for subtle tinting. - pub fn message_background_color(message_type: MessageType, dark_mode: bool) -> Color32 { - let alpha = if dark_mode { 30 } else { 20 }; - match message_type { - MessageType::Error => { - let c = if dark_mode { (255, 100, 100) } else { (235, 87, 87) }; - Color32::from_rgba_unmultiplied(c.0, c.1, c.2, alpha) - } - MessageType::Warning => { - let c = if dark_mode { (255, 200, 100) } else { (241, 196, 15) }; - Color32::from_rgba_unmultiplied(c.0, c.1, c.2, alpha) - } - MessageType::Success => { - let c = if dark_mode { (80, 200, 120) } else { (39, 174, 96) }; - Color32::from_rgba_unmultiplied(c.0, c.1, c.2, alpha) - } - MessageType::Info => { - let c = if dark_mode { (100, 180, 255) } else { (52, 152, 219) }; - Color32::from_rgba_unmultiplied(c.0, c.1, c.2, alpha) - } - } - } - - /// Returns the foreground (text/border) color for a message severity level. - /// Delegates to existing per-severity color methods. - pub fn message_color(message_type: MessageType, dark_mode: bool) -> Color32 { - match message_type { - MessageType::Error => Self::error_color(dark_mode), - MessageType::Warning => Self::warning_color(dark_mode), - MessageType::Success => Self::success_color(dark_mode), - MessageType::Info => Self::info_color(dark_mode), - } - } +struct BannerState { + key: u64, // Unique ID from AtomicU64 counter + text: String, // Display text + message_type: MessageType, // Error | Warning | Success | Info + created_at: Instant, // Monotonic timestamp for timing + auto_dismiss_after: Option, // None = persistent, Some = countdown + show_elapsed: bool, // Show elapsed time instead of countdown } ``` -This approach: -- Keeps all color definitions in `DashColors` (single source of truth for the design system) -- Reuses existing `error_color`/`success_color`/`warning_color` methods for foreground colors -- Fills the gap: `info_color(dark_mode)` was missing -- Replaces the dead `theme.rs::MessageType` color methods with dark-mode-aware equivalents - ---- - -## 2. MessageBanner Component - -### Alignment with Component Design Pattern - -`MessageBanner` follows the conventions established in `doc/COMPONENT_DESIGN_PATTERN.md`: - -| Convention | How MessageBanner Applies It | -|---|---| -| **Private fields only** | `state: Option` is private | -| **`new()` constructor** | `MessageBanner::new()` creates an empty banner | -| **Builder methods** | `with_auto_dismiss_duration()` for configurable timeout | -| **`show()` method** | `show(&mut self, ui: &mut Ui)` renders the banner | -| **Self-contained** | Handles rendering, dismiss, auto-dismiss, and color resolution internally | -| **Colors via design system** | Uses `DashColors::message_color()`, `DashColors::message_background_color()`, and `DashColors::text_secondary()` — no hardcoded colors | - -**Why MessageBanner does NOT implement the `Component` trait:** - -The `Component` trait (in `component_trait.rs`) is designed for **input** components that produce domain values — `AmountInput` produces `Amount`, `ConfirmationDialog` produces `ConfirmationStatus`. Each has `DomainType`, `ComponentResponse` with `has_changed()`/`changed_value()`/`update()`, and `current_value()`. +### MessageType -`MessageBanner` is a **display** component that **consumes** data (receives messages) rather than **producing** domain values. It has no meaningful `DomainType` to bind, no user-produced value to return, and no `update()` target. Forcing the `Component` trait would require artificial type parameters (`DomainType = ()`) that add complexity without value. +The unified enum in `src/ui/mod.rs` with four variants: `Error`, `Warning`, `Success`, `Info`. Colors are resolved via `DashColors::message_color(type, dark_mode)` and `DashColors::message_background_color(type, dark_mode)`. -This is consistent with how other display-only widgets in the codebase work — e.g., `StyledButton`, `GradientHeading`, `InfoPopup` all have `show()` without implementing `Component`. +### BannerHandle -### File Location - -`src/ui/components/message_banner.rs` - -Register in `src/ui/components/mod.rs`: -```rust -pub mod message_banner; -pub use message_banner::MessageBanner; -``` - -### Struct Definition +A `'static` handle returned by `set_global` and `replace_global`. Identifies a banner by its internal `u64` key, allowing text updates and configuration changes after creation: ```rust -use crate::ui::MessageType; -use crate::ui::theme::DashColors; -use std::time::{Duration, Instant}; - -const DEFAULT_AUTO_DISMISS_DURATION: Duration = Duration::from_secs(5); - -/// A self-contained banner widget for displaying screen-level messages. -/// -/// Each screen owns one instance. Call `show()` every frame inside the -/// screen's `ui()` method, before the `ScrollArea`. The banner renders -/// nothing when no message is set. -/// -/// Follows the component conventions from `doc/COMPONENT_DESIGN_PATTERN.md`: -/// private fields, `new()` constructor, builder methods, `show()` rendering. -pub struct MessageBanner { - /// Current message state: text, severity, and the instant it was set. - /// `None` means no message is displayed. - state: Option, - /// Duration before Success/Info messages auto-dismiss. - auto_dismiss_duration: Duration, -} - -struct MessageState { - text: String, - message_type: MessageType, - created_at: Instant, +pub struct BannerHandle { + ctx: egui::Context, // egui Context is Arc>, cheap to clone + key: u64, // Unique key assigned at creation } ``` -The struct is intentionally minimal. `state` is `Option` so the banner occupies zero layout space when empty. `Instant` is used for auto-dismiss timing because it is monotonic and does not depend on wall clock. +All query/mutation methods return `Option` to handle the case where the banner has been dismissed or expired: -### Public API +| Method | Signature | Purpose | +|--------|-----------|---------| +| `elapsed()` | `-> Option` | Time since creation (looked up from context data, not stored on handle) | +| `set_message()` | `(&self, text: &str) -> Option<&Self>` | Update display text | +| `with_auto_dismiss()` | `(&self, Duration) -> Option<&Self>` | Set/override countdown duration; resets timer to now | +| `with_elapsed()` | `-> Option<&Self>` | Enable elapsed-time display mode (disables auto-dismiss) | +| `clear()` | `(self)` | Remove banner immediately (consumes handle) | -```rust -impl MessageBanner { - /// Creates an empty banner (no message displayed). - pub fn new() -> Self { - Self { - state: None, - auto_dismiss_duration: DEFAULT_AUTO_DISMISS_DURATION, - } - } - - /// Builder: set custom auto-dismiss duration for Success/Info messages. - pub fn with_auto_dismiss_duration(mut self, duration: Duration) -> Self { - self.auto_dismiss_duration = duration; - self - } - - /// 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) { - if text.is_empty() { - self.state = None; - return; - } - self.state = Some(MessageState { - text: text.to_string(), - message_type, - created_at: Instant::now(), - }); - } - - /// Clears the current message immediately. - pub fn clear(&mut self) { - self.state = None; - } - - /// Returns whether a message is currently displayed. - pub fn has_message(&self) -> bool { - self.state.is_some() - } - - /// Renders the banner into the given `Ui`. - /// Call this every frame before the ScrollArea. - pub fn show(&mut self, ui: &mut egui::Ui) { - // Implementation below - } -} +Methods that modify the banner (`set_message`, `with_auto_dismiss`, `with_elapsed`) return early without writing back to context data when the banner no longer exists. -impl Default for MessageBanner { - fn default() -> Self { - Self::new() - } -} -``` +--- -### Rendering Implementation (`show`) +## 3. Global API -All colors are resolved through `DashColors` methods — no hardcoded `Color32` values in the component. +### Multi-Message Support -```rust -pub fn show(&mut self, ui: &mut egui::Ui) { - // 1. Check if there is a message to display - let Some(state) = &self.state else { return }; - - // 2. Auto-dismiss check for Success and Info - let auto_dismiss = matches!(state.message_type, MessageType::Success | MessageType::Info); - if auto_dismiss && state.created_at.elapsed() >= self.auto_dismiss_duration { - self.state = None; - return; - } - - // 3. Resolve colors via DashColors (single source of truth) - let dark_mode = ui.ctx().style().visuals.dark_mode; - let fg_color = DashColors::message_color(state.message_type, dark_mode); - let bg_color = DashColors::message_background_color(state.message_type, dark_mode); - let secondary_color = DashColors::text_secondary(dark_mode); - - // 4. Compute remaining seconds for countdown (only for auto-dismiss types) - let remaining_secs = if auto_dismiss { - let elapsed = state.created_at.elapsed(); - self.auto_dismiss_duration - .checked_sub(elapsed) - .map(|d| d.as_secs() + 1) // +1 so we show "1s" until it actually expires - } else { - None - }; - - // 5. Render using DashColors and theme constants - let icon = icon_for_type(state.message_type); - let text = state.text.clone(); // clone to release borrow on self - let mut dismissed = false; - - egui::Frame::new() - .fill(bg_color) - .inner_margin(egui::Margin::symmetric( - Spacing::SM_I8 + Spacing::XXS as i8, // 10px - Spacing::SM_I8, // 8px - )) - .corner_radius(Shape::RADIUS_SM as f32) // 6px - .stroke(egui::Stroke::new(Shape::BORDER_WIDTH, fg_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - // Icon - ui.label(egui::RichText::new(icon).color(fg_color).strong()); - ui.add_space(Spacing::XS); // 4px - - // Message text (wrapping allowed via Label) - ui.label(egui::RichText::new(&text).color(fg_color)); - - // Flexible space - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - // Dismiss button (rightmost) - if ui.small_button("x").clicked() { - dismissed = true; - } - - // Countdown (before dismiss button in RTL layout) - if let Some(secs) = remaining_secs { - ui.label( - egui::RichText::new(format!("({}s)", secs)) - .font(Typography::body_small()) - .color(secondary_color), - ); - } - }); - }); - }); - ui.add_space(Spacing::SM as f32); // 8px below banner - - if dismissed { - self.state = None; - } -} -``` +The global store is a `Vec` in egui's temporary context data, keyed by `egui::Id::new("__global_message_banner")`. Multiple messages can coexist (capped at `MAX_BANNERS = 5`, oldest evicted first). -### Color Resolution — All Through DashColors +| Method | Signature | Behavior | +|--------|-----------|----------| +| `set_global` | `(ctx, text, type) -> BannerHandle` | Add message (dedup by text). Returns handle. | +| `replace_global` | `(ctx, old_text, new_text, type) -> BannerHandle` | Find by `old_text`, replace (resets timer, auto-dismiss, and `show_elapsed`). Falls back to `set_global` if not found. | +| `clear_global_message` | `(ctx, text)` | Remove specific message by text match. | +| `has_global` | `(ctx) -> bool` | Any messages present? | +| `show_global` | `(ui)` | Render all banners, handle auto-dismiss/elapsed, remove expired. | -The banner uses **zero hardcoded colors**. All color resolution goes through `DashColors`: +**Deduplication**: `set_global` with the same text returns a handle to the existing banner without creating a duplicate. Key-based lookup is used after handle creation. -| Purpose | Method | Source | -|---|---|---| -| Text & border color | `DashColors::message_color(type, dark_mode)` | Delegates to `error_color()` / `success_color()` / `warning_color()` / `info_color()` | -| Background tint | `DashColors::message_background_color(type, dark_mode)` | Semantic backgrounds at 8% (light) / 12% (dark) alpha | -| Countdown text | `DashColors::text_secondary(dark_mode)` | Standard secondary text color | -| Spacing values | `Spacing::XS`, `Spacing::SM`, etc. | Theme constants | -| Corner radius | `Shape::RADIUS_SM` (6px) | Theme constants | -| Border width | `Shape::BORDER_WIDTH` (1px) | Theme constants | +**Empty text**: `set_global("")` is a no-op (returns a dead handle). `replace_global(old, "", type)` clears the old message. -This ensures that if the design system colors are updated globally, the banner automatically reflects the changes. +### Auto-Dismiss Defaults -### Icon Selection +| MessageType | Default | +|-------------|---------| +| Success | 5 seconds countdown | +| Info | 5 seconds countdown | +| Error | Persistent (manual dismiss only) | +| Warning | Persistent (manual dismiss only) | -```rust -fn icon_for_type(message_type: MessageType) -> &'static str { - match message_type { - MessageType::Error => "\u{26A0}", // ⚠ warning sign (visually distinct via color) - MessageType::Warning => "\u{26A0}", // ⚠ same glyph, differentiated by color - MessageType::Success => "\u{2713}", // ✓ check mark - MessageType::Info => "\u{2139}", // ℹ info - } -} -``` +### Elapsed-Time Mode -Note: If the egui font does not render these Unicode characters, fall back to ASCII: `"!"`, `"!"`, `"v"`, `"i"`. The implementer should verify glyph availability at development time. +Calling `handle.with_elapsed()` on a banner switches it to elapsed-time display: shows `(Ns)` counting up from 0, and disables auto-dismiss. Used for long-running operations like identity refresh. --- -## 3. ScreenLike Trait Changes +## 4. Per-Instance API -### Current Signature (unchanged) +`MessageBanner` as a struct implements the `Component` trait: ```rust -pub trait ScreenLike { - fn refresh(&mut self) {} - fn refresh_on_arrival(&mut self) { self.refresh() } - fn ui(&mut self, ctx: &Context) -> AppAction; - 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) - } - fn pop_on_success(&mut self) {} +pub struct MessageBanner { + state: Option, } ``` -**No signature change is needed.** The `display_message` method signature already accepts `&str` and `MessageType`. Adding `Warning` to the `MessageType` enum is the only change, and it flows through the existing signature. +| Method | Purpose | +|--------|---------| +| `new()` | Empty banner | +| `set_message(text, type)` | Set/replace message (empty text clears) | +| `set_auto_dismiss(duration)` | Override auto-dismiss duration | +| `clear()` | Remove message | +| `has_message()` | Check if displaying | -### Default Implementation Consideration +`MessageBanner` also implements `Default` (equivalent to `new()`). -It would be tempting to add a default `display_message` implementation that delegates to a `banner` field. However, `ScreenLike` is a trait and has no access to struct fields, so no default implementation is possible without an additional accessor method. - -**Option considered and rejected:** Adding a `fn banner(&mut self) -> &mut MessageBanner` method to ScreenLike. This would save boilerplate but forces every screen to have a field named in a specific way, and changes the trait contract for all 45+ implementors simultaneously. The cost of the two-line `display_message` implementation per screen is low. - -**Recommendation:** Each screen implements `display_message` as a two-line method: - -```rust -fn display_message(&mut self, message: &str, message_type: MessageType) { - self.banner.set_message(message, message_type); -} -``` - -Some screens perform additional logic in `display_message` (e.g., `TopUpIdentityScreen` resets its step state on error). Those screens keep their custom logic and add the `banner.set_message()` call. +The `Component` trait implementation: +- `DomainType = BannerStatus` (enum: `Visible`, `Dismissed`, `TimedOut`) +- `Response = MessageBannerResponse` — struct with `pub status: Option` and private `changed: bool` +- `show(ui)` renders the banner and returns `InnerResponse` +- `current_value()` returns `Some(Visible)` when a message is set --- -## 4. Migration Plan - -### Phase 1: Create Component + Unify MessageType + Extend DashColors - -**Scope:** 3 files changed, 1 file created - -1. **Update `src/ui/mod.rs`** -- Add `Warning` variant to `MessageType` enum (line 830). -2. **Update `src/ui/theme.rs`** -- Add `info_color(dark_mode)`, `message_color(message_type, dark_mode)`, and `message_background_color(message_type, dark_mode)` to `DashColors`. Delete the dead-code `MessageType` enum and its `impl` block (lines 636-664). -3. **Create `src/ui/components/message_banner.rs`** -- Full `MessageBanner` implementation as described in Section 2, using only `DashColors` for colors and theme constants for spacing/shape. -4. **Update `src/ui/components/mod.rs`** -- Add `pub mod message_banner; pub use message_banner::MessageBanner;` -5. **Fix compile errors** -- Any exhaustive `match` on `MessageType` will need a `Warning` arm. These are likely limited to `app.rs` (the `TaskResult` routing) and any screen that matches on message type in `display_message`. Add `MessageType::Warning => { /* same as Error for now */ }` to each. - -**Validation:** `cargo build` succeeds. No behavioral change yet. - -### Phase 2: Migrate Screens Incrementally +## 5. Rendering -Each screen migration follows the same mechanical pattern. Screens can be migrated one at a time in separate commits, enabling incremental review. +Both global and per-instance paths call `render_banner()`: -**Per-screen changes:** - -1. Add `use crate::ui::components::MessageBanner;` (if not already via glob import) -2. Replace `error_message: Option` (and similar fields) with `banner: MessageBanner` -3. In `new()` / constructor: replace `error_message: None` with `banner: MessageBanner::new()` -4. In `ui()`: replace the inline `Frame` error rendering block with `self.banner.show(ui);` -5. In `display_message()`: replace `self.error_message = Some(...)` with `self.banner.set_message(message, message_type);` -6. Remove any manual dismiss logic (the banner handles it) -7. If the screen sets `self.error_message = Some(...)` inline during `ui()`, replace with `self.banner.set_message(...)` -8. If the screen clears the error (e.g., `self.error_message = None`), replace with `self.banner.clear()` - -**Estimated effort per screen:** ~5-15 lines changed per screen file. Mechanical, low risk. - -**Migration order suggestion:** -1. Start with a simple screen (e.g., `transfer_screen.rs`) as a proof-of-concept -2. Then migrate screens with the exact duplicate pattern (the `Frame` + dismiss button block identical to `top_up_identity_screen`) -3. Then migrate screens with custom `display_message` logic -4. Finally migrate screens that use status enums with `ErrorMessage(String)` variants +``` +render_banner(ui, text, message_type, annotation: Option<&str>) -> bool (dismissed?) +``` -### Phase 3: Remove Dead Code +The `annotation` parameter is generic — it receives either a countdown string `"(3s)"`, an elapsed string `"(5s)"`, or `None` for persistent banners. This is computed by `process_banner()` which handles the lifecycle logic. -1. Delete the `#[allow(dead_code)]` annotations from `theme.rs` if the dead `MessageType` was removed in Phase 1 -2. Remove any helper functions that were only used for the old error rendering pattern (if any exist) -3. Run `cargo clippy` to find any remaining unused code related to the old pattern +### Visual Structure ---- +``` ++-----------------------------------------------------------------------+ +| [Icon] Message text here [5s] [x] | ++-----------------------------------------------------------------------+ +``` -## 5. What Stays Unchanged +- Frame: `DashColors::message_background_color()` fill, `DashColors::message_color()` border +- Icon: Unicode character (⚠ for Error/Warning, ✓ for Success, ℹ for Info) +- Text: `DashColors::message_color()` foreground +- Annotation: `DashColors::text_secondary()` color, `Typography::body_small()` font +- Dismiss: `ui.small_button("x")` +- Spacing: `Spacing::SM` below each banner -| Concern | Status | Rationale | -|---------|--------|-----------| -| **Status enums** (Pattern 2, e.g., `TransferCreditsStatus::ErrorMessage(String)`) | Keep as-is | These track screen workflow state. The screen can use both a status enum for flow control and `MessageBanner` for display. When entering the `ErrorMessage` state, the screen calls `self.banner.set_message(...)`. | -| **`show_success_screen()` helper** (Pattern 6, `src/ui/helpers.rs`) | Keep as-is | This renders a full-page success view with detailed info, not a banner. Different purpose entirely. | -| **Inline validation** (Pattern 8, e.g., `AmountInput` error messages) | Keep as-is | These are field-level validation hints rendered next to the input. They are not screen-level messages. | -| **`ConfirmationDialog`** | Keep as-is | Modal dialog for confirming actions. Orthogonal to message banners. | -| **BackendTask / TaskResult / AppState** | Keep as-is | The task routing in `app.rs` calls `display_message()` on the visible screen. This works identically with `MessageBanner` -- the screen's `display_message` impl just delegates to `banner.set_message()`. | -| **`AppAction` enum** | Keep as-is | No new action variants needed. | -| **`BackendTaskSuccessResult`** | Keep as-is | No changes to result types. | -| **`component_trait.rs` (`Component` / `ComponentResponse`)** | Not implemented by `MessageBanner` | `Component` trait is for input widgets that produce domain values (`AmountInput` → `Amount`). `MessageBanner` is a display widget that consumes data. It follows the pattern's conventions (private fields, builder, `show()`, DashColors) without the trait. Same approach as `StyledButton`, `GradientHeading`, `InfoPopup`. | +All colors are resolved via `DashColors` methods — zero hardcoded `Color32` values. --- -## 6. Code Examples: Before/After Migration - -### Example: `TopUpIdentityScreen` +## 6. AppState Integration -#### Before (current code) +`AppState::update()` in `src/app.rs` sets global banners automatically for all task results: -**Struct definition** (`src/ui/identities/top_up_identity_screen/mod.rs:44-66`): -```rust -pub struct TopUpIdentityScreen { - pub identity: QualifiedIdentity, - // ... other fields ... - error_message: Option, - // ... -} -``` - -**Constructor:** -```rust -impl TopUpIdentityScreen { - pub fn new(qualified_identity: QualifiedIdentity, app_context: &Arc) -> Self { - Self { - // ... - error_message: None, - // ... - } - } -} ``` +TaskResult::Error(message) + → MessageBanner::set_global(ctx, &message, MessageType::Error) + → screen.display_message(&message, MessageType::Error) // for side-effects -**Error rendering in `ui()`** (lines 531-552): -```rust -// Display error message at the top, outside of scroll area -if let Some(error_message) = self.error_message.clone() { - let message_color = egui::Color32::from_rgb(255, 100, 100); - - ui.horizontal(|ui| { - egui::Frame::new() - .fill(message_color.gamma_multiply(0.1)) - .inner_margin(egui::Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, message_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label(egui::RichText::new(&error_message).color(message_color)); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.error_message = None; - } - }); - }); - }); - ui.add_space(10.0); -} +TaskResult::Success (default) + → MessageBanner::set_global(ctx, "Success", MessageType::Success) + → screen.display_task_result(result) ``` -**`display_message` impl** (lines 430-435): -```rust -fn display_message(&mut self, message: &str, message_type: MessageType) { - if message_type == MessageType::Error { - self.error_message = Some(format!("Error topping up identity: {}", message)); - // Reset step so UI is not stuck on waiting messages - let mut step = self.step.write().unwrap(); - if *step == WalletFundedScreenStep::WaitingForPlatformAcceptance - || *step == WalletFundedScreenStep::WaitingForAssetLock - { - *step = WalletFundedScreenStep::ChooseFundingMethod; - } - } -} -``` +The call to `screen.display_message()` is retained alongside `set_global` so screens can perform side-effects (e.g., resetting step state on error, clearing refresh handles) without being responsible for rendering. -#### After (migrated) +### Rendering Point -**Struct definition:** -```rust -use crate::ui::components::MessageBanner; +`island_central_panel()` in `src/ui/components/styled.rs` calls `MessageBanner::show_global(ui)` before screen content. All screens render through this function, providing a single consistent insertion point. -pub struct TopUpIdentityScreen { - pub identity: QualifiedIdentity, - // ... other fields ... - banner: MessageBanner, // replaces error_message: Option - // ... -} -``` +--- -**Constructor:** -```rust -Self { - // ... - banner: MessageBanner::new(), // replaces error_message: None - // ... -} -``` +## 7. Usage Example: IdentitiesScreen -**Rendering in `ui()`** (replaces the 20-line block): -```rust -// Display message banner at the top, outside of scroll area -self.banner.show(ui); -``` +Demonstrates the `BannerHandle` + elapsed-time pattern for a long-running refresh operation: -**`display_message` impl** (keeps custom step-reset logic): ```rust -fn display_message(&mut self, message: &str, message_type: MessageType) { - self.banner.set_message(message, message_type); - if message_type == MessageType::Error { - // Reset step so UI is not stuck on waiting messages - let mut step = self.step.write().unwrap(); - if *step == WalletFundedScreenStep::WaitingForPlatformAcceptance - || *step == WalletFundedScreenStep::WaitingForAssetLock - { - *step = WalletFundedScreenStep::ChooseFundingMethod; - } - } -} -``` - -Note that the error message prefix `"Error topping up identity: "` is dropped. The banner's visual styling (red color, error icon) already communicates that it is an error. The backend task error string itself is descriptive enough. - -### Example: Simple Screen (no custom `display_message`) +// Struct: stores handle instead of separate refreshing/timestamp fields +refresh_banner: Option, -Many screens have no custom `display_message` override and rely on the default no-op. After migration, these screens get a one-line `display_message`: +// Starting refresh (in ui()): +let handle = MessageBanner::set_global(ctx, "Refreshing identities...", MessageType::Info); +handle.with_elapsed(); +self.refresh_banner = Some(handle); -```rust -fn display_message(&mut self, message: &str, message_type: MessageType) { - self.banner.set_message(message, message_type); +// On success (in display_task_result() — no ctx parameter available): +if let Some(handle) = self.refresh_banner.take() { + handle.clear(); } -``` - -And a one-line render call in `ui()`: +MessageBanner::set_global( + self.app_context.egui_ctx(), + "Successfully refreshed identity", + MessageType::Success, +); -```rust -self.banner.show(ui); +// On error (in display_message() — side-effect only): +if let MessageType::Error = message_type + && let Some(handle) = self.refresh_banner.take() +{ + handle.clear(); +} ``` --- -## 7. Architectural Decisions Summary +## 8. Key Design Decisions | Decision | Rationale | |----------|-----------| -| Per-screen ownership (not global) | Matches egui immediate-mode model. No shared mutable state. Each screen is self-contained. | -| `Instant` for timing (not egui frame time) | Monotonic, works correctly even if frames are missed while screen is not visible. | -| Follows Component Design Pattern conventions, not `Component` trait | `MessageBanner` is a display widget (consumes data), not an input widget (produces domain values). It follows the pattern's conventions (private fields, builder, `show()`, DashColors) like `StyledButton`/`InfoPopup`. | -| All colors via `DashColors` methods | Zero hardcoded `Color32` values. New `DashColors::message_color()`, `message_background_color()`, and `info_color()` methods ensure single source of truth. If the design system is updated, the banner reflects changes automatically. | -| Delete dead `theme.rs::MessageType`, consolidate into `DashColors` | The dead enum had light-mode-only `color()`/`background_color()` methods. The replacement `DashColors::message_color(type, dark_mode)` and `DashColors::message_background_color(type, dark_mode)` are dark-mode-aware and reusable by any component. | -| No accessor method on `ScreenLike` | Avoids coupling the trait to a specific field name. The two-line `display_message` boilerplate is acceptable. | -| Incremental migration (one screen at a time) | Reduces risk. Each migration is a small, reviewable change. Old and new patterns coexist during migration. | +| Global banners via egui context data | All screens render through `island_central_panel()`, making global placement consistent. Eliminates per-screen rendering boilerplate. | +| Multi-message support (max 5) | Backend tasks may produce multiple results; they should all be visible. Cap prevents unbounded growth. | +| Text-based dedup at creation, key-based lookup after | Prevents duplicate "Success" banners from rapid task completions while allowing handle-based text updates. | +| `BannerHandle` as `'static` struct | Handles can be stored in screen fields (e.g., `refresh_banner: Option`) without lifetime issues. | +| `elapsed()` looks up from context data | Avoids data redundancy — `created_at` lives only in `BannerState`, not duplicated on the handle. | +| All handle methods return `Option` | Handles may outlive their banners (dismissed, expired). Callers must handle the `None` case. | +| `Instant` for timing | Monotonic, immune to system clock changes. Correct for timeout and elapsed-time logic. | +| Per-instance implements `Component` trait | `DomainType = BannerStatus` allows screens to react to banner lifecycle events (dismiss, timeout) through the standard component interface. | +| All colors via `DashColors` methods | Zero hardcoded colors. Theme changes propagate automatically. | +| Retained `screen.display_message()` call | Screens still need side-effect hooks (reset step state, clear refresh handles). The global banner handles display; `display_message()` handles side-effects only. | --- -## 8. Alternatives Considered - -### Alternative A: Global App-Level Banner (Rejected) - -Render the banner in `AppState::update()` above all screens, eliminating per-screen ownership. - -**Pros:** Zero migration effort for backend task errors — `AppState` would call `banner.set_message()` directly instead of routing through `display_message()`. - -**Rejected because:** Screens render inside panels with different layouts (left sidebar, main content, modals). A global banner can't know the correct placement within each screen's layout. Also, screens that set validation errors during `ui()` would still need their own banner. Result: two systems instead of one. - -### Alternative B: Implement `Component` Trait (Rejected) - -Make `MessageBanner` implement `Component`. - -**Pros:** Perfectly uniform with `AmountInput` and `ConfirmationDialog`. - -**Rejected because:** `Component` requires `DomainType` (the value the component produces), `ComponentResponse` with `has_changed()`/`changed_value()`/`update()`, and `current_value()`. A display widget has no meaningful domain value to produce. Using `DomainType = ()` and `changed_value() -> &Option<()>` would be semantically empty. The pattern doc itself describes components that "handle wallet selection" or "manage passwords" — input components. Display-only widgets (`StyledButton`, `GradientHeading`, `InfoPopup`) don't use the trait either. - -### Alternative C: Add `fn banner() -> &mut MessageBanner` to ScreenLike (Rejected) - -Add an accessor method to the trait so `AppState` could call `screen.banner().set_message()` directly. - -**Pros:** Eliminates the two-line `display_message` boilerplate per screen. - -**Rejected because:** Forces every `ScreenLike` implementor to have a `MessageBanner` field (including screens that legitimately don't need messages, like `ProofLogScreen`). Changing the trait contract for 50+ screens simultaneously increases migration risk. +## 9. Migration Status -### Alternative D: Use `DateTime` for Timing (Rejected) +3 of ~50 screens migrated as proof-of-concept: -8 screens already use `DateTime` for timed messages. +| Screen | Old Pattern | Migration Notes | +|--------|-------------|-----------------| +| `TopUpIdentityScreen` | Framed banner + Dismiss | Removed `error_message` field and 20-line Frame block. `display_message()` retains step-reset side-effect. | +| `IdentitiesScreen` | Timed badge with DateTime | Removed `backend_message` field and helpers. Uses `BannerHandle` with `with_elapsed()` for refresh tracking. | +| `MintTokensScreen` | Bare colored_label + status enum | Changed `ErrorMessage(String)` to `Error` (no payload). Banner displays via global API. | -**Rejected because:** `Instant` is monotonic (immune to system clock changes), simpler (no chrono dependency needed), and purpose-built for duration measurement. `DateTime` is correct for display timestamps but wrong for timeout logic. +Old and new patterns coexist without conflict because the global banner renders above screen content via `island_central_panel`. --- -## 9. Implementation Notes - -This section documents how the implementation diverged from the original design and the patterns that emerged during development. - -### 9.1 Architecture Deviation: Global Banner Instead of Per-Screen - -The original design (Sections 2-7 above) specified **per-screen ownership** where each screen holds a `MessageBanner` instance. The implementation instead uses a **global banner** stored in egui context data and rendered centrally by `island_central_panel()`. - -**Why the change:** The user requested centralized error handling where backend task errors display automatically without requiring every screen to wire up `display_message()` overrides. With a global banner, `AppState::update()` sets the message once and it appears on whatever screen is currently visible. No per-screen rendering code is needed for basic error/success display. - -**Why the original concern was resolved:** Section 8, Alternative A rejected the global approach because "screens render inside panels with different layouts" and "a global banner can't know the correct placement." In practice, all 58 screens render through `island_central_panel()` (in `src/ui/components/styled.rs`), which provides a single consistent insertion point at the top of the content area, before screen content. This makes the placement concern moot. - -### 9.2 Global API - -The `MessageBanner` component (`src/ui/components/message_banner.rs`) exposes four static methods for global state management. State is stored in egui's temporary context data using `egui::Id::new("__global_message_banner")`. - -| Method | Signature | Purpose | -|--------|-----------|---------| -| `set_global` | `(ctx: &egui::Context, text: &str, message_type: MessageType)` | Sets or replaces the global banner message. Empty text clears. | -| `clear_global` | `(ctx: &egui::Context)` | Removes the global banner state from context data. | -| `show_global` | `(ui: &mut egui::Ui)` | Renders the banner, handles auto-dismiss and countdown. Called by `island_central_panel`. | -| `has_global` | `(ctx: &egui::Context) -> bool` | Checks whether a global message exists (useful for tests). | - -The underlying `BannerState` struct is `#[derive(Clone)]` because egui context data requires `Clone`. It stores `text: String`, `message_type: MessageType`, and `created_at: Instant`. - -### 9.3 AppState Integration - -`AppState::update()` in `src/app.rs` sets the global banner automatically for all task results: - -``` -TaskResult::Error(message) - -> MessageBanner::set_global(ctx, &message, MessageType::Error) - -> screen.display_message(&message, MessageType::Error) // for side-effects - -TaskResult::Success (default branch) - -> MessageBanner::set_global(ctx, "Success", MessageType::Success) - -> screen.display_task_result(result) - -TaskResult::Success (specific: UpdatedThemePreference) - -> MessageBanner::set_global(ctx, "Theme preference updated successfully", MessageType::Success) - -TaskResult::Success (specific: CastScheduledVote) - -> MessageBanner::set_global(ctx, "Successfully cast scheduled vote", MessageType::Success) -``` - -The call to `screen.display_message()` is retained alongside `set_global` so that screens can still perform side-effects (e.g., resetting step state on error) without being responsible for rendering. - -### 9.4 Screen Migration Pattern - -Migrated screens follow this pattern: - -1. **Remove error display fields** (`error_message: Option`, `backend_message: Option<(String, MessageType, DateTime)>`, etc.) -2. **Remove rendering code** (the `Frame`/`colored_label` blocks in `ui()`) -3. **Simplify `display_message()`** to only retain side-effect logic (e.g., resetting step state). The method no longer needs to store the error text since the global banner handles display. -4. **Add `pending_banner_message` field** for messages that originate in `display_task_result()`, which does not receive the egui `Context`. These are flushed at the top of `ui()`: - -```rust -// In display_task_result(): -self.pending_banner_message = Some(("Successfully refreshed identity".to_string(), MessageType::Success)); - -// At the top of ui(): -if let Some((text, msg_type)) = self.pending_banner_message.take() { - MessageBanner::set_global(ctx, &text, msg_type); -} -``` - -For validation errors set during `ui()`, screens call `MessageBanner::set_global(ctx, ...)` directly since they have the egui `Context`. - -### 9.5 Per-Instance API - -The `MessageBanner` struct with per-instance methods (`new()`, `set_message()`, `clear()`, `show()`, `has_message()`) still exists alongside the global API. It shares rendering logic with the global path via a private `render_banner()` function. - -This per-instance API is available for potential future use cases such as screen-local banners displayed alongside the global one (e.g., a screen that needs a secondary notification area). Currently no screens use the per-instance API; its methods carry `#[allow(dead_code)]`. - -### 9.6 Migration Status +## 10. Pre-Migration Analysis -3 of ~50 screens have been migrated as a proof-of-concept, each demonstrating a different migration path: +Before the MessageBanner was implemented, the codebase had: -| Screen | Pattern | Migration Notes | -|--------|---------|-----------------| -| `TopUpIdentityScreen` | A (Framed Banner + Dismiss) | Removed `error_message` field and 20-line Frame rendering block. `display_message()` retains step-reset side-effect. | -| `IdentitiesScreen` | B (Timed Badge with DateTime) | Removed `backend_message` field, `check_message_expiration()`, and `dismiss_message()` helpers. Uses `pending_banner_message` for messages from `display_task_result()`. | -| `MintTokensScreen` | D (Bare colored_label + status enum) | Changed `MintTokensStatus::ErrorMessage(String)` to `MintTokensStatus::Error` (no payload). Uses `pending_banner_message` for validation errors set during construction and in `confirmation_ok()`. | +- **~50 screens** with no unified message rendering +- **4 distinct rendering patterns**: Framed Banner, Timed Badge, Status Enum, Bare colored_label +- **8+ different error colors**: `Color32::RED`, `DARK_RED`, `from_rgb(255,100,100)`, `from_rgb(220,80,80)`, `DashColors::error_color()`, `DashColors::ERROR`, etc. +- **Two competing `MessageType` enums**: active 3-variant in `mod.rs`, dead 4-variant in `theme.rs` -The remaining ~47 screens continue to use their existing per-screen error rendering. Old and new patterns coexist without conflict because the global banner renders above screen content via `island_central_panel`. +The dead `theme.rs::MessageType` was deleted. The active enum gained a `Warning` variant. Color methods were consolidated into `DashColors::message_color()` and `message_background_color()`. A new `info_color()` helper was added to complement the existing `error_color()`/`success_color()`/`warning_color()` methods. diff --git a/docs/ai-design/2026-02-17-unified-messages/message-banner-ux-spec.md b/docs/ai-design/2026-02-17-unified-messages/message-banner-ux-spec.md index 45123dd03..cd325a0cd 100644 --- a/docs/ai-design/2026-02-17-unified-messages/message-banner-ux-spec.md +++ b/docs/ai-design/2026-02-17-unified-messages/message-banner-ux-spec.md @@ -2,20 +2,25 @@ ## 1. Overview -The `MessageBanner` is a self-contained egui component that replaces all ad-hoc `error_message: Option` fields across screens. Each screen owns one `MessageBanner` instance (not global). It displays a single message at a time with consistent styling based on severity. +The `MessageBanner` renders user-facing messages with consistent styling based on severity. It operates in two modes: + +- **Global mode**: Multiple banners stored in egui context data, rendered centrally by `island_central_panel()` before screen content. This is the primary mode — `AppState::update()` sets banners automatically for all backend task results. +- **Per-instance mode**: A screen owns a `MessageBanner` struct for screen-local messages. Shares rendering logic with global mode. + +Multiple messages can be displayed simultaneously (capped at 5). Each banner is independently dismissible and independently timed. --- ## 2. Severity Levels -| Severity | Persistence | Dismiss | Use Case | -|----------|---------------------|--------------------|-----------------------------------------------| -| Error | Persistent | Manual only | Task failures, validation errors | -| Warning | Persistent | Manual only | Risky actions, degraded state | -| Success | Auto-dismiss ~5s | Manual or auto | Completed operations | -| Info | Auto-dismiss ~5s | Manual or auto | Informational feedback, neutral status updates | +| Severity | Default Persistence | Dismiss | Use Case | +|----------|-------------------|---------|----------| +| Error | Persistent | Manual only | Task failures, validation errors | +| Warning | Persistent | Manual only | Risky actions, degraded state | +| Success | Auto-dismiss 5s | Manual or auto | Completed operations | +| Info | Auto-dismiss 5s | Manual or auto | Informational feedback, status updates | -All four types display a dismiss button. Success and Info also count down and auto-clear. +All four types display a dismiss button (`x`). Success and Info also show a countdown label. Any banner can be switched to elapsed-time mode via `handle.with_elapsed()`, which disables auto-dismiss and shows time since creation. --- @@ -25,106 +30,67 @@ All four types display a dismiss button. Success and Info also count down and au ``` +-----------------------------------------------------------------------+ -| [Icon] Message text here [5s] [Dismiss] | +| [Icon] Message text here [5s] [x] | +-----------------------------------------------------------------------+ ``` -The banner is a single horizontal row inside an `egui::Frame` with: -- **Corner radius**: 6px (`Shape::RADIUS_SM`) -- **Inner margin**: 10px horizontal, 8px vertical (matching existing pattern in `top_up_identity_screen`) -- **Outer spacing**: 10px below the banner (`ui.add_space(10.0)`) -- **Full available width**: The frame should expand to `ui.available_width()` +The banner is a horizontal row inside an `egui::Frame` with: +- **Corner radius**: `Shape::RADIUS_SM` (6px) +- **Inner margin**: 10px horizontal, 8px vertical +- **Outer spacing**: `Spacing::SM` below each banner +- **Full available width**: Frame expands to `ui.available_width()` ### 3.2 Content Arrangement (left to right) -1. **Icon character** (Unicode text, not image): Provides at-a-glance severity recognition. - - Error: `!` (exclamation in circle, or literal `!` if unicode unavailable) - - Warning: `!` (triangle-style -- visually distinct from error via color) - - Success: Checkmark character - - Info: `i` (info style) - - Font size: `Typography::SCALE_BASE` (16px), bold - - Color: Same as the text color for that severity +1. **Icon** (Unicode): `⚠` (Error/Warning), `✓` (Success), `ℹ` (Info) + - Color: Same as text color for severity + - Style: `RichText::strong()` 2. **4px gap** (`Spacing::XS`) -3. **Message text**: Left-aligned, wrapping permitted for long messages. - - Font: `Typography::body()` (16px proportional) - - Color: Severity-specific foreground color (see Section 3.3) +3. **Message text**: Left-aligned. Long text may be clipped within the horizontal layout. + - Font: Default egui label font (matches app-wide body text size) + - Color: Severity-specific foreground -4. **Flexible space** (push remaining elements to the right) +4. **Flexible space** (right-to-left layout for remaining elements) -5. **Countdown label** (Success and Info only): Shows remaining seconds, e.g., `(3s)`. +5. **Annotation** (optional): Shows remaining seconds `(3s)` or elapsed seconds `(5s)` - Font: `Typography::body_small()` (14px) - - Color: Same as message text, at reduced opacity or using `text_secondary` + - Color: `DashColors::text_secondary(dark_mode)` -6. **Dismiss button**: Small text button labeled with an `x` character. - - Uses `ui.small_button("x")` - - Clicking sets the banner state to None +6. **Dismiss button**: `ui.small_button("x")` ### 3.3 Color Palette -All colors are resolved through `DashColors` methods — the banner contains zero hardcoded color values. The banner uses a **tinted background + colored border + colored text** approach. - -#### Color Resolution via DashColors - -| Purpose | DashColors Method | Description | -|---|---|---| -| Text & border | `DashColors::message_color(type, dark_mode)` | Delegates to `error_color()` / `success_color()` / `warning_color()` / `info_color()` | -| Background tint | `DashColors::message_background_color(type, dark_mode)` | Severity color at 8% alpha (light) or 12% alpha (dark) | -| Countdown text | `DashColors::text_secondary(dark_mode)` | Standard secondary text color | - -#### Resolved Values (for visual reference) - -**Light Mode:** - -| Severity | Background | Text & Border | -|----------|-----------|---------------| -| Error | `ERROR` at 8% alpha | `DashColors::error_color(false)` → `DARK_RED` | -| Warning | `WARNING` at 8% alpha | `DashColors::warning_color(false)` → dark amber | -| Success | `SUCCESS` at 8% alpha | `DashColors::success_color(false)` → `DARK_GREEN` | -| Info | `INFO` at 8% alpha | `DashColors::info_color(false)` → `DEEP_BLUE` | - -**Dark Mode:** - -| Severity | Background | Text & Border | -|----------|-----------|---------------| -| Error | lighter red at 12% alpha | `DashColors::error_color(true)` → `rgb(255, 100, 100)` | -| Warning | lighter amber at 12% alpha | `DashColors::warning_color(true)` → `rgb(255, 200, 100)` | -| Success | muted green at 12% alpha | `DashColors::success_color(true)` → `rgb(80, 160, 80)` | -| Info | light blue at 12% alpha | `DashColors::info_color(true)` → `rgb(100, 180, 255)` | - -Dark mode uses higher alpha backgrounds (12% vs 8%) to maintain visibility against dark surfaces. +All colors resolved through `DashColors` — zero hardcoded values. -#### Border Stroke -- Width: `Shape::BORDER_WIDTH` (1px) -- Color: `DashColors::message_color(type, dark_mode)` — same as text color +| Purpose | Method | +|---------|--------| +| Text & border | `DashColors::message_color(type, dark_mode)` | +| Background tint | `DashColors::message_background_color(type, dark_mode)` | +| Annotation text | `DashColors::text_secondary(dark_mode)` | -### 3.4 Typography - -- Icon: `Typography::SCALE_BASE` (16px), bold (`RichText::strong()`) -- Message body: `Typography::body()` (16px), normal weight -- Countdown: `Typography::body_small()` (14px), normal weight, secondary text color -- Dismiss button: egui default `small_button` styling +Background uses low alpha (8% light, 12% dark) for subtle tinting. Border uses `Shape::BORDER_WIDTH` (1px) in the foreground color. --- ## 4. Placement -The banner renders at the **top of the screen's content area**, before the `ScrollArea`. This matches the existing convention in `top_up_identity_screen/mod.rs:531-552` and `add_new_identity_screen/mod.rs:1071-1092`. +Global banners render at the top of the content area inside `island_central_panel()`, before any screen content: ``` +--------------------------------------------------+ | Top Panel (header / navigation) | +--------------------------------------------------+ -| Left Panel | [ MessageBanner ] | <-- here -| | +----- ScrollArea -----+ | -| | | Screen content | | -| | | ... | | -| | +----------------------+ | +| Left Panel | [ Banner 1 ] | +| | [ Banner 2 ] | +| | +----- Screen Content -----+ | +| | | ... | | +| | +--------------------------+ | +--------------------------------------------------+ ``` -The banner must be rendered **outside** the `ScrollArea` so it remains visible regardless of scroll position. This is consistent with current best practice in the codebase. +Banners remain visible regardless of scroll position because they render outside `ScrollArea`. --- @@ -132,140 +98,66 @@ The banner must be rendered **outside** the `ScrollArea` so it remains visible r ### 5.1 Showing a Message -Calling `banner.set_message("text", MessageType::Error)` replaces any currently displayed message. There is no queue. The new message immediately takes effect. - -For auto-dismissing types (Success, Info), the component records the timestamp when the message was set (using `Instant::now()` or egui frame time). +`MessageBanner::set_global(ctx, text, type)` adds a banner. If a banner with the same text already exists, the call is deduplicated (returns a handle to the existing banner). ### 5.2 Auto-Dismiss (Success, Info) -- Duration: 5 seconds -- The countdown label shows remaining whole seconds: `(5s)`, `(4s)`, ..., `(1s)` -- When the timer expires, the message clears automatically on the next frame -- The screen must call `banner.show(ui)` each frame (standard egui immediate mode) -- The component internally checks elapsed time and clears itself - -### 5.3 Manual Dismiss - -- All severity types display a dismiss button -- Clicking the dismiss button immediately clears the message -- No confirmation needed - -### 5.4 Message Replacement +- Default duration: 5 seconds +- Countdown label: `(5s)`, `(4s)`, ..., `(1s)` +- Banner clears automatically when timer expires +- Component requests repaint every 1s for countdown updates -- Setting a new message while one is showing replaces it immediately -- If the old message was Error (persistent) and the new one is Success (auto-dismiss), the Success behavior applies -- Timer resets on replacement +### 5.3 Elapsed-Time Mode -### 5.5 Screen Navigation +Calling `handle.with_elapsed()` switches a banner to elapsed-time display: +- Shows `(0s)`, `(1s)`, `(2s)`, ... counting up +- Auto-dismiss is disabled (banner persists until manually cleared) +- Used for long-running operations (e.g., identity refresh) -- When the user navigates away from a screen and returns, persistent messages (Error, Warning) should still be visible if the screen struct was retained (root screens in `main_screens` BTreeMap) -- Auto-dismiss messages that expired while the screen was not visible should be gone on return -- Modal/detail screens pushed onto `screen_stack` are destroyed when popped, so their messages naturally disappear +### 5.4 Manual Dismiss ---- +- All severity types display a dismiss (`x`) button +- Clicking clears that specific banner immediately +- Other banners are unaffected -## 6. Component API (Behavioral Spec) +### 5.5 Message Replacement -This describes the public interface the component should expose. Not a full Rust implementation, but a behavioral contract for the architect and implementer. +`MessageBanner::replace_global(ctx, old_text, new_text, type)` finds a banner by old text and replaces it. If old text is not found, the new text is added as a new banner. If new text is empty, the old banner is removed. -``` -MessageBanner - State: - - message: Option<(String, MessageType, Instant)> - - Methods: - - new() -> Self // empty, no message - - set_message(text: &str, msg_type: MessageType) // set/replace message - - clear() // manually clear - - show(ui: &mut Ui) // render; auto-dismiss check happens here - - MessageType (unified, replaces both existing enums): - - Error - - Warning - - Success - - Info -``` +### 5.6 BannerHandle Lifecycle -The component does NOT implement the `Component` trait from `component_trait.rs` because it has no domain data to bind via `update()`. It is a simpler display-only widget. Screens call `show(ui)` and `set_message(...)` directly. +Handles returned by `set_global`/`replace_global` can outlive their banners. All handle methods return `Option` — `None` means the banner has been dismissed, expired, or cleared. --- -## 7. Integration Points - -### 7.1 ScreenLike Trait - -The existing `display_message(&mut self, message: &str, message_type: MessageType)` method on `ScreenLike` is the integration point. Screens that adopt `MessageBanner` implement it as: - -``` -fn display_message(&mut self, message: &str, message_type: MessageType) { - self.banner.set_message(message, message_type); -} -``` - -### 7.2 Replacing Existing Fields - -Each screen replaces its ad-hoc fields: -- `error_message: Option` -> removed -- `info_message: Option` -> removed -- `message: Option<(String, MessageType)>` -> removed -- `backend_message: Option<(String, MessageType, DateTime)>` -> removed - -All replaced by a single: -- `banner: MessageBanner` - -### 7.3 Sync Error Display (Validation) - -For validation errors set during `ui()`: - -``` -if some_validation_fails { - self.banner.set_message("Invalid input: ...", MessageType::Error); -} -``` - ---- - -## 8. Edge Cases +## 6. Edge Cases | Scenario | Behavior | |----------|----------| -| Very long message (300+ chars) | Text wraps within the frame. Frame grows vertically. No truncation. | -| Empty string message | Treated as no message; banner not shown. | -| Rapid message replacement | Each call to `set_message` replaces immediately. No debounce. | -| Multiple error fields on one screen | All consolidated into a single banner. If multiple errors need display, concatenate them with newlines before calling `set_message`. | -| Screen with multiple independent sections | Each section could own its own `MessageBanner` if needed, but the default is one per screen. | -| Theme change while message is showing | Colors re-evaluate each frame via `ui.ctx().style().visuals.dark_mode`. No stale colors. | +| Very long message (300+ chars) | Text may be clipped within the horizontal layout. No explicit truncation or wrapping. | +| Empty string message | `set_global("")` is a no-op. Per-instance `set_message("")` clears. | +| Duplicate text | `set_global` returns handle to existing banner (idempotent). | +| More than 5 messages | Oldest message is evicted. | +| Rapid message replacement | Each call replaces/adds immediately. No debounce. | +| Theme change while showing | Colors re-evaluated each frame via `DashColors`. No stale colors. | +| Handle used after banner cleared | All methods return `None`. No panic. | +| Banner expired while screen not visible | Expired on next `show_global()` call when screen becomes visible. | --- -## 9. Accessibility +## 7. Accessibility -- **Color contrast**: All text/background combinations meet WCAG 2.1 AA contrast ratio (4.5:1 minimum). The colored text on tinted backgrounds achieves this because the backgrounds are near-transparent (8-12% alpha) over the page background. -- **Not color-only**: The icon character provides a non-color severity indicator (distinct shapes for error vs warning vs success vs info). -- **Text is selectable**: egui labels allow text selection by default. -- **Keyboard**: The dismiss button is focusable and activatable via keyboard in egui's default tab-order. No special focus management needed. -- **Screen readers**: Not directly applicable (egui does not have native screen reader support), but the text content is programmatically accessible via egui's accessibility layer if enabled. +- **Color contrast**: Text on near-transparent backgrounds (8-12% alpha) meets WCAG 2.1 AA (4.5:1 minimum). +- **Not color-only**: Icon character provides non-color severity indicator. +- **Text selectable**: egui labels allow text selection. +- **Keyboard**: Dismiss button is focusable via egui's default tab-order. --- -## 10. What This Spec Does NOT Cover +## 8. What This Spec Does NOT Cover -- Toast/notification stacking (out of scope -- one message per screen) -- Animation or transitions (egui does not support CSS-style transitions; show/hide is instant) +- Toast/notification stacking beyond the 5-banner cap +- Animation or transitions (egui show/hide is instant) - Sound or haptic feedback - Message persistence across app restarts -- Global overlay banners -- Changes to BackendTask/TaskResult/AppState architecture - ---- - -## 11. Migration Strategy (UX Perspective) - -The visual result after migration should be: -1. Every screen shows messages in exactly the same visual style -2. Error messages appear in the same position (top of content, before scroll area) -3. Success messages auto-clear after 5 seconds with a visible countdown -4. No more inline `colored_label` errors scattered at arbitrary positions in screen layouts -5. The Warning severity becomes available for the first time (currently missing from `MessageType` in `mod.rs`) - -Screens that currently use `colored_label` for inline validation hints (e.g., "Field is required") are a separate concern and should remain inline. The `MessageBanner` replaces only the screen-level status/result messages. +- Changes to BackendTask/TaskResult/AppState routing architecture From 54a9e27372926c8e1f9ca42027dbc87df89737c2 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 18 Feb 2026 16:52:01 +0100 Subject: [PATCH 05/46] chore: self review --- src/ui/components/message_banner.rs | 35 +++++--------- tests/kittest/message_banner.rs | 71 +++++++++++++++++++++++++---- 2 files changed, 73 insertions(+), 33 deletions(-) diff --git a/src/ui/components/message_banner.rs b/src/ui/components/message_banner.rs index b7e4d915b..476f9c6d3 100644 --- a/src/ui/components/message_banner.rs +++ b/src/ui/components/message_banner.rs @@ -93,14 +93,10 @@ impl BannerHandle { /// Returns `None` if the banner no longer exists. pub fn set_message(&self, text: &str) -> Option<&Self> { let mut banners = get_banners(&self.ctx); - let found = if let Some(b) = banners.iter_mut().find(|b| b.key == self.key) { - b.text = text.to_string(); - true - } else { - false - }; + let b = banners.iter_mut().find(|b| b.key == self.key)?; + b.text = text.to_string(); set_banners(&self.ctx, banners); - found.then_some(self) + Some(self) } /// Override the auto-dismiss duration for this banner. @@ -108,15 +104,11 @@ impl BannerHandle { /// Returns `None` if the banner no longer exists. pub fn with_auto_dismiss(&self, duration: Duration) -> Option<&Self> { let mut banners = get_banners(&self.ctx); - let found = if let Some(b) = banners.iter_mut().find(|b| b.key == self.key) { - b.auto_dismiss_after = Some(duration); - b.created_at = Instant::now(); - true - } else { - false - }; + let b = banners.iter_mut().find(|b| b.key == self.key)?; + b.auto_dismiss_after = Some(duration); + b.created_at = Instant::now(); set_banners(&self.ctx, banners); - found.then_some(self) + Some(self) } /// Enable elapsed-time display on this banner. Disables auto-dismiss @@ -124,15 +116,11 @@ impl BannerHandle { /// Returns `None` if the banner no longer exists. pub fn with_elapsed(&self) -> Option<&Self> { let mut banners = get_banners(&self.ctx); - let found = if let Some(b) = banners.iter_mut().find(|b| b.key == self.key) { - b.show_elapsed = true; - b.auto_dismiss_after = None; - true - } else { - false - }; + let b = banners.iter_mut().find(|b| b.key == self.key)?; + b.show_elapsed = true; + b.auto_dismiss_after = None; set_banners(&self.ctx, banners); - found.then_some(self) + Some(self) } /// Remove this banner immediately. @@ -267,6 +255,7 @@ impl MessageBanner { b.message_type = message_type; b.created_at = Instant::now(); b.auto_dismiss_after = default_auto_dismiss(message_type); + b.show_elapsed = false; } else if let Some(existing) = banners.iter().find(|b| b.text == new_text) { key = existing.key; } else { diff --git a/tests/kittest/message_banner.rs b/tests/kittest/message_banner.rs index 26db9c1d0..40f4c86a9 100644 --- a/tests/kittest/message_banner.rs +++ b/tests/kittest/message_banner.rs @@ -94,9 +94,21 @@ fn test_banner_renders_all_types() { }); harness.run(); // Message text, icon, and dismiss button should all be present - assert!(harness.query_by_label(text).is_some(), "Missing text for {:?}", msg_type); - assert!(harness.query_by_label(icon).is_some(), "Missing icon for {:?}", msg_type); - assert!(harness.query_by_label("x").is_some(), "Missing dismiss button for {:?}", msg_type); + assert!( + harness.query_by_label(text).is_some(), + "Missing text for {:?}", + msg_type + ); + assert!( + harness.query_by_label(icon).is_some(), + "Missing icon for {:?}", + msg_type + ); + assert!( + harness.query_by_label("x").is_some(), + "Missing dismiss button for {:?}", + msg_type + ); } } @@ -152,8 +164,7 @@ fn test_deduplication() { fn test_replace_global_finds_and_replaces() { let ctx = egui::Context::default(); - let original_handle = - MessageBanner::set_global(&ctx, "Generic success", MessageType::Success); + let original_handle = MessageBanner::set_global(&ctx, "Generic success", MessageType::Success); MessageBanner::set_global(&ctx, "An error", MessageType::Error); // Replace the generic one @@ -434,10 +445,50 @@ fn test_handle_with_auto_dismiss_on_live_banner() { let ctx = egui::Context::default(); let handle = MessageBanner::set_global(&ctx, "Dismissable", MessageType::Error); - assert!( - handle - .with_auto_dismiss(Duration::from_secs(30)) - .is_some() - ); + assert!(handle.with_auto_dismiss(Duration::from_secs(30)).is_some()); assert!(handle.elapsed().is_some()); } + +/// Test that exceeding MAX_BANNERS (5) evicts the oldest banner. +#[test] +fn test_max_banners_eviction() { + let ctx = egui::Context::default(); + + let handle1 = MessageBanner::set_global(&ctx, "Banner 1", MessageType::Error); + let handle2 = MessageBanner::set_global(&ctx, "Banner 2", MessageType::Error); + let _handle3 = MessageBanner::set_global(&ctx, "Banner 3", MessageType::Error); + let _handle4 = MessageBanner::set_global(&ctx, "Banner 4", MessageType::Error); + let _handle5 = MessageBanner::set_global(&ctx, "Banner 5", MessageType::Error); + + // All 5 should be alive + assert!(handle1.elapsed().is_some()); + assert!(handle2.elapsed().is_some()); + + // Adding a 6th should evict the oldest (Banner 1) + let handle6 = MessageBanner::set_global(&ctx, "Banner 6", MessageType::Error); + assert!(handle1.elapsed().is_none()); // evicted + assert!(handle2.elapsed().is_some()); // still alive + assert!(handle6.elapsed().is_some()); // newly added + + // Adding a 7th should evict Banner 2 + let _handle7 = MessageBanner::set_global(&ctx, "Banner 7", MessageType::Error); + assert!(handle2.elapsed().is_none()); // evicted +} + +/// Test that replace_global resets show_elapsed flag. +#[test] +fn test_replace_global_resets_show_elapsed() { + let ctx = egui::Context::default(); + + let handle = MessageBanner::set_global(&ctx, "Loading...", MessageType::Info); + handle.with_elapsed(); + + // Replace should reset show_elapsed and set fresh auto_dismiss + let replaced = MessageBanner::replace_global(&ctx, "Loading...", "Done!", MessageType::Success); + assert!(replaced.elapsed().is_some()); + + // The replaced banner should have auto-dismiss (Success type default), + // not the elapsed mode from the original + // We verify by checking it's still alive (just created, within 5s window) + assert!(replaced.elapsed().unwrap() < Duration::from_secs(1)); +} From 343f411500124b33278b89af3c8204ecffa0d4f0 Mon Sep 17 00:00:00 2001 From: Pasta Lil Claw Date: Wed, 18 Feb 2026 04:40:09 -0600 Subject: [PATCH 06/46] refactor(context): replace RwLock with ArcSwap (#600) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(context): replace RwLock with ArcSwap Sdk is internally thread-safe (Arc, ArcSwapOption, atomics) and all methods take &self. The RwLock was adding unnecessary contention across backend tasks. Using ArcSwap instead of plain Sdk because reinit_core_client_and_sdk() needs to atomically swap the entire Sdk instance when config changes. ArcSwap provides lock-free reads with atomic swap for the rare write. Suggested-by: lklimek * fix: address CodeRabbit review findings for ArcSwap migration - Fix import ordering: move arc_swap::ArcSwap before crossbeam_channel - Remove redundant SDK loads in load_identity_from_wallet, register_dpns_name, and load_identity — use the sdk parameter already passed to these functions - Fix stale TODO referencing removed sdk.read().unwrap() API - Rename sdk_guard → sdk in transfer, withdraw_from_identity, and refresh_loaded_identities_dpns_names (no longer lock guards) - Pass &sdk to run_platform_info_task from dispatch site instead of reloading internally - Fix leftover sdk.write() call in context_provider.rs (RwLock remnant) - Add missing Color32 import in wallets dialogs Co-Authored-By: Claude Opus 4.6 * refactor: address remaining CodeRabbit review feedback on ArcSwap migration - Move SDK load outside for loop in refresh_loaded_identities_dpns_names.rs so it's loaded once for the batch instead of on each iteration - Update stale TODO comment in default_platform_version() to reflect that this is a free function with no sdk access Co-Authored-By: Claude Opus 4.6 * refactor: consolidate double read-lock on spv_context_provider Clone the SPV provider in a single lock acquisition, then bind app context on the clone instead of locking twice. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: PastaClaw Co-authored-by: Claude Opus 4.6 --- Cargo.lock | 1 + Cargo.toml | 1 + .../identity/discover_identities.rs | 2 +- src/backend_task/identity/load_identity.rs | 7 +-- .../identity/load_identity_from_wallet.rs | 7 +-- .../refresh_loaded_identities_dpns_names.rs | 9 ++-- .../identity/register_dpns_name.rs | 9 +--- .../identity/register_identity.rs | 10 +--- src/backend_task/identity/top_up_identity.rs | 5 +- src/backend_task/identity/transfer.rs | 7 +-- .../identity/withdraw_from_identity.rs | 9 ++-- src/backend_task/mod.rs | 7 +-- src/backend_task/platform_info.rs | 23 ++++---- .../wallet/fetch_platform_address_balances.rs | 5 +- .../fund_platform_address_from_asset_lock.rs | 2 +- ...fund_platform_address_from_wallet_utxos.rs | 2 +- .../wallet/transfer_platform_credits.rs | 2 +- .../wallet/withdraw_from_platform_address.rs | 2 +- src/context/connection_status.rs | 3 +- src/context/mod.rs | 54 +++++-------------- src/context/wallet_lifecycle.rs | 5 +- src/context_provider.rs | 6 +-- src/ui/wallets/wallets_screen/dialogs.rs | 2 +- 23 files changed, 53 insertions(+), 127 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d1a8f4e41..1f4117fd2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1800,6 +1800,7 @@ version = "1.0.0-dev" dependencies = [ "aes-gcm", "arboard", + "arc-swap", "argon2", "base64 0.22.1", "bincode 2.0.1", diff --git a/Cargo.toml b/Cargo.toml index 8f2313ae1..dfffb323d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,6 +73,7 @@ humantime = "2.2.0" which = { version = "8.0.0" } tz-rs = { version = "0.7.0" } tempfile = "3.20.0" +arc-swap = "1" [target.'cfg(not(target_os = "windows"))'.dependencies] zmq = "0.10.0" diff --git a/src/backend_task/identity/discover_identities.rs b/src/backend_task/identity/discover_identities.rs index 9d77af0eb..c16b61e09 100644 --- a/src/backend_task/identity/discover_identities.rs +++ b/src/backend_task/identity/discover_identities.rs @@ -18,7 +18,7 @@ impl AppContext { const AUTH_KEY_LOOKUP_WINDOW: u32 = 12; - let sdk = self.sdk.read().map_err(|e| e.to_string())?.clone(); + let sdk = self.sdk.load().as_ref().clone(); let seed_hash = wallet.read().map_err(|e| e.to_string())?.seed_hash(); tracing::info!( diff --git a/src/backend_task/identity/load_identity.rs b/src/backend_task/identity/load_identity.rs index c5b332f37..08038cf80 100644 --- a/src/backend_task/identity/load_identity.rs +++ b/src/backend_task/identity/load_identity.rs @@ -285,12 +285,7 @@ impl AppContext { start: None, }; - let sdk_guard = { - let guard = self.sdk.read().unwrap(); - guard.clone() - }; - - let maybe_owned_dpns_names = Document::fetch_many(&sdk_guard, dpns_names_document_query) + let maybe_owned_dpns_names = Document::fetch_many(sdk, dpns_names_document_query) .await .map(|document_map| { document_map diff --git a/src/backend_task/identity/load_identity_from_wallet.rs b/src/backend_task/identity/load_identity_from_wallet.rs index 8ccc3ba4c..8f51fb0a3 100644 --- a/src/backend_task/identity/load_identity_from_wallet.rs +++ b/src/backend_task/identity/load_identity_from_wallet.rs @@ -115,12 +115,7 @@ impl AppContext { start: None, }; - let sdk_guard = { - let guard = self.sdk.read().unwrap(); - guard.clone() - }; - - let maybe_owned_dpns_names = Document::fetch_many(&sdk_guard, dpns_names_document_query) + let maybe_owned_dpns_names = Document::fetch_many(sdk, dpns_names_document_query) .await .map(|document_map| { document_map diff --git a/src/backend_task/identity/refresh_loaded_identities_dpns_names.rs b/src/backend_task/identity/refresh_loaded_identities_dpns_names.rs index 2f480d085..a017e2541 100644 --- a/src/backend_task/identity/refresh_loaded_identities_dpns_names.rs +++ b/src/backend_task/identity/refresh_loaded_identities_dpns_names.rs @@ -17,6 +17,8 @@ impl AppContext { .load_local_qualified_identities() .map_err(|e| format!("Error refreshing owned DPNS names: Database error: {}", e))?; + let sdk = self.sdk.load().as_ref().clone(); + for mut qualified_identity in qualified_identities { let identity_id = qualified_identity.identity.id(); @@ -34,12 +36,7 @@ impl AppContext { start: None, }; - let sdk_guard = { - let guard = self.sdk.read().unwrap(); - guard.clone() - }; - - let owned_dpns_names = Document::fetch_many(&sdk_guard, dpns_names_document_query) + let owned_dpns_names = Document::fetch_many(&sdk, dpns_names_document_query) .await .map(|document_map| { document_map diff --git a/src/backend_task/identity/register_dpns_name.rs b/src/backend_task/identity/register_dpns_name.rs index 1520aef09..baee1f9c1 100644 --- a/src/backend_task/identity/register_dpns_name.rs +++ b/src/backend_task/identity/register_dpns_name.rs @@ -178,12 +178,7 @@ impl AppContext { start: None, }; - let sdk_guard = { - let guard = self.sdk.read().unwrap(); - guard.clone() - }; - - let owned_dpns_names = Document::fetch_many(&sdk_guard, dpns_names_document_query) + let owned_dpns_names = Document::fetch_many(sdk, dpns_names_document_query) .await .map(|document_map| { document_map @@ -222,7 +217,7 @@ impl AppContext { // Calculate actual fee paid // Note: We need to re-fetch the identity to get the updated balance let refreshed_identity = dash_sdk::platform::Identity::fetch_by_identifier( - &sdk_guard, + sdk, qualified_identity.identity.id(), ) .await diff --git a/src/backend_task/identity/register_identity.rs b/src/backend_task/identity/register_identity.rs index 47ae6fa5d..8b48db79a 100644 --- a/src/backend_task/identity/register_identity.rs +++ b/src/backend_task/identity/register_identity.rs @@ -36,10 +36,7 @@ impl AppContext { identity_funding_method, } = input; - let sdk = { - let guard = self.sdk.read().unwrap(); - guard.clone() - }; + let sdk = self.sdk.load().as_ref().clone(); let (_, metadata) = ExtendedEpochInfo::fetch_with_metadata(&sdk, 0, None) .await @@ -646,10 +643,7 @@ impl AppContext { ) -> Result { use dash_sdk::platform::transition::put_identity::PutIdentity; - let sdk = { - let guard = self.sdk.read().unwrap(); - guard.clone() - }; + let sdk = self.sdk.load().as_ref().clone(); let public_keys = keys.to_public_keys_map(); diff --git a/src/backend_task/identity/top_up_identity.rs b/src/backend_task/identity/top_up_identity.rs index 76d115a26..93c89c461 100644 --- a/src/backend_task/identity/top_up_identity.rs +++ b/src/backend_task/identity/top_up_identity.rs @@ -28,10 +28,7 @@ impl AppContext { identity_funding_method, } = input; - let sdk = { - let guard = self.sdk.read().unwrap(); - guard.clone() - }; + let sdk = self.sdk.load().as_ref().clone(); let (_, metadata) = ExtendedEpochInfo::fetch_with_metadata(&sdk, 0, None) .await diff --git a/src/backend_task/identity/transfer.rs b/src/backend_task/identity/transfer.rs index 84c58af0b..78e39b9d4 100644 --- a/src/backend_task/identity/transfer.rs +++ b/src/backend_task/identity/transfer.rs @@ -18,10 +18,7 @@ impl AppContext { credits: Credits, id: Option, ) -> Result { - let sdk_guard = { - let guard = self.sdk.read().unwrap(); - guard.clone() - }; + let sdk = self.sdk.load().as_ref().clone(); // Track balance before transfer for fee calculation let balance_before = qualified_identity.identity.balance(); @@ -31,7 +28,7 @@ impl AppContext { .identity .clone() .transfer_credits( - &sdk_guard, + &sdk, to_identifier, credits, id.and_then(|key_id| qualified_identity.identity.get_public_key_by_id(key_id)), diff --git a/src/backend_task/identity/withdraw_from_identity.rs b/src/backend_task/identity/withdraw_from_identity.rs index 1370e3ae5..871f4fe77 100644 --- a/src/backend_task/identity/withdraw_from_identity.rs +++ b/src/backend_task/identity/withdraw_from_identity.rs @@ -21,10 +21,7 @@ impl AppContext { credits: Credits, id: Option, ) -> Result { - let sdk_guard = { - let guard = self.sdk.read().unwrap(); - guard.clone() - }; + let sdk = self.sdk.load().as_ref().clone(); // First, refresh the identity from Platform to get the latest revision and balance tracing::info!( @@ -34,7 +31,7 @@ impl AppContext { ); let refreshed_identity = - Identity::fetch_by_identifier(&sdk_guard, qualified_identity.identity.id()) + Identity::fetch_by_identifier(&sdk, qualified_identity.identity.id()) .await .map_err(|e| format!("Failed to fetch identity from Platform: {}", e))? .ok_or_else(|| "Identity not found on Platform".to_string())?; @@ -89,7 +86,7 @@ impl AppContext { .identity .clone() .withdraw( - &sdk_guard, + &sdk, to_address, credits, Some(1), diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index 7d8413fad..656c0d2a9 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -311,10 +311,7 @@ impl AppContext { task: BackendTask, sender: SenderAsync, ) -> Result { - let sdk = { - let guard = self.sdk.read().unwrap(); - guard.clone() - }; + let sdk = self.sdk.load().as_ref().clone(); match task { BackendTask::ContractTask(contract_task) => { self.run_contract_task(*contract_task, &sdk, sender).await @@ -345,7 +342,7 @@ impl AppContext { mnlist::run_mnlist_task(self, mnlist_task).await } BackendTask::PlatformInfo(platform_info_task) => { - self.run_platform_info_task(platform_info_task).await + self.run_platform_info_task(platform_info_task, &sdk).await } BackendTask::GroveSTARKTask(grovestark_task) => { grovestark::run_grovestark_task(grovestark_task, &sdk).await diff --git a/src/backend_task/platform_info.rs b/src/backend_task/platform_info.rs index 6cbf246f0..eed11ee2e 100644 --- a/src/backend_task/platform_info.rs +++ b/src/backend_task/platform_info.rs @@ -1,5 +1,6 @@ use crate::backend_task::BackendTaskSuccessResult; use crate::context::AppContext; +use dash_sdk::Sdk; use dash_sdk::dashcore_rpc::RpcApi; use dash_sdk::dpp::block::extended_epoch_info::{v0::ExtendedEpochInfoV0Getters, ExtendedEpochInfo}; use dash_sdk::dpp::core_types::validator_set::v0::ValidatorSetV0Getters; @@ -332,12 +333,8 @@ impl AppContext { pub async fn run_platform_info_task( &self, request: PlatformInfoTaskRequestType, + sdk: &Sdk, ) -> Result { - let sdk = { - let sdk_guard = self.sdk.read().unwrap(); - sdk_guard.clone() - }; - match request { PlatformInfoTaskRequestType::BasicPlatformInfo => { // Get platform version from SDK @@ -365,7 +362,7 @@ impl AppContext { )) } PlatformInfoTaskRequestType::CurrentEpochInfo => { - match ExtendedEpochInfo::fetch_current(&sdk).await { + match ExtendedEpochInfo::fetch_current(sdk).await { Ok(epoch_info) => { // Cache the fee multiplier for UI fee estimation let fee_multiplier = epoch_info.fee_multiplier_permille(); @@ -385,7 +382,7 @@ impl AppContext { } } PlatformInfoTaskRequestType::TotalCreditsOnPlatform => { - match TotalCreditsInPlatform::fetch_current(&sdk).await { + match TotalCreditsInPlatform::fetch_current(sdk).await { Ok(total_credits) => { let dash_amount = total_credits.0 as f64 * 10f64.powf(-11.0); let formatted = format!( @@ -402,7 +399,7 @@ impl AppContext { } } PlatformInfoTaskRequestType::CurrentVersionVotingState => { - match ProtocolVersionVoteCount::fetch_many(&sdk, ()).await { + match ProtocolVersionVoteCount::fetch_many(sdk, ()).await { Ok(votes) => { let votes: ProtocolVersionUpgrades = votes; let votes_info = votes @@ -428,7 +425,7 @@ impl AppContext { } } PlatformInfoTaskRequestType::CurrentValidatorSetInfo => { - match CurrentQuorumsInfo::fetch_unproved(&sdk, NoParamQuery {}).await { + match CurrentQuorumsInfo::fetch_unproved(sdk, NoParamQuery {}).await { Ok(Some(current_quorums_info)) => { let formatted = format_current_quorums_info(¤t_quorums_info); Ok(BackendTaskSuccessResult::PlatformInfo( @@ -461,13 +458,13 @@ impl AppContext { start: None, }; - match Document::fetch_many(&sdk, queued_document_query.clone()).await { + match Document::fetch_many(sdk, queued_document_query.clone()).await { Ok(documents) => { let withdrawal_docs: Vec = documents.values().filter_map(|a| a.clone()).collect(); // Try to get total credits for daily limit calculation - match TotalCreditsInPlatform::fetch_current(&sdk).await { + match TotalCreditsInPlatform::fetch_current(sdk).await { Ok(total_credits) => { let formatted = format_withdrawal_documents_with_daily_limit( &withdrawal_docs, @@ -526,7 +523,7 @@ impl AppContext { start: None, }; - match Document::fetch_many(&sdk, completed_document_query).await { + match Document::fetch_many(sdk, completed_document_query).await { Ok(documents) => { let mut withdrawal_docs: Vec = documents.values().filter_map(|a| a.clone()).collect(); @@ -633,7 +630,7 @@ impl AppContext { // Fetch the address info using FetchMany with BTreeSet let mut addresses = std::collections::BTreeSet::new(); addresses.insert(platform_address); - match AddressInfo::fetch_many(&sdk, addresses).await { + match AddressInfo::fetch_many(sdk, addresses).await { Ok(address_infos) => { // The result is a map of PlatformAddress -> Option let result: Option<&Option> = diff --git a/src/backend_task/wallet/fetch_platform_address_balances.rs b/src/backend_task/wallet/fetch_platform_address_balances.rs index d07f7e030..737129fd3 100644 --- a/src/backend_task/wallet/fetch_platform_address_balances.rs +++ b/src/backend_task/wallet/fetch_platform_address_balances.rs @@ -76,10 +76,7 @@ impl AppContext { }; // Sync using SDK's privacy-preserving method - let sdk = { - let guard = self.sdk.read().map_err(|e| e.to_string())?; - guard.clone() - }; + let sdk = self.sdk.load().as_ref().clone(); let (_checkpoint_height, highest_block_processed) = if needs_full_sync { tracing::info!( diff --git a/src/backend_task/wallet/fund_platform_address_from_asset_lock.rs b/src/backend_task/wallet/fund_platform_address_from_asset_lock.rs index 02983677c..f189b7fc2 100644 --- a/src/backend_task/wallet/fund_platform_address_from_asset_lock.rs +++ b/src/backend_task/wallet/fund_platform_address_from_asset_lock.rs @@ -34,7 +34,7 @@ impl AppContext { .ok_or_else(|| "Wallet not found".to_string())? }; let wallet = wallet_arc.read().map_err(|e| e.to_string())?.clone(); - let sdk = self.sdk.read().map_err(|e| e.to_string())?.clone(); + let sdk = self.sdk.load().as_ref().clone(); // Get the private key for the asset lock address let private_key = wallet diff --git a/src/backend_task/wallet/fund_platform_address_from_wallet_utxos.rs b/src/backend_task/wallet/fund_platform_address_from_wallet_utxos.rs index aa67973ab..ee95378e9 100644 --- a/src/backend_task/wallet/fund_platform_address_from_wallet_utxos.rs +++ b/src/backend_task/wallet/fund_platform_address_from_wallet_utxos.rs @@ -203,7 +203,7 @@ impl AppContext { }; let wallet = wallet_arc.read().map_err(|e| e.to_string())?.clone(); - let sdk = self.sdk.read().map_err(|e| e.to_string())?.clone(); + let sdk = self.sdk.load().as_ref().clone(); (wallet, sdk, change_platform_address) }; diff --git a/src/backend_task/wallet/transfer_platform_credits.rs b/src/backend_task/wallet/transfer_platform_credits.rs index 3549af4be..0d580a0dd 100644 --- a/src/backend_task/wallet/transfer_platform_credits.rs +++ b/src/backend_task/wallet/transfer_platform_credits.rs @@ -28,7 +28,7 @@ impl AppContext { .ok_or_else(|| "Wallet not found".to_string())? }; let wallet = wallet_arc.read().map_err(|e| e.to_string())?.clone(); - let sdk = self.sdk.read().map_err(|e| e.to_string())?.clone(); + let sdk = self.sdk.load().as_ref().clone(); (wallet, sdk) }; diff --git a/src/backend_task/wallet/withdraw_from_platform_address.rs b/src/backend_task/wallet/withdraw_from_platform_address.rs index a12b8948f..f717943e4 100644 --- a/src/backend_task/wallet/withdraw_from_platform_address.rs +++ b/src/backend_task/wallet/withdraw_from_platform_address.rs @@ -32,7 +32,7 @@ impl AppContext { .ok_or_else(|| "Wallet not found".to_string())? }; let wallet = wallet_arc.read().map_err(|e| e.to_string())?.clone(); - let sdk = self.sdk.read().map_err(|e| e.to_string())?.clone(); + let sdk = self.sdk.load().as_ref().clone(); (wallet, sdk) }; diff --git a/src/context/connection_status.rs b/src/context/connection_status.rs index 9672804cf..882b631b7 100644 --- a/src/context/connection_status.rs +++ b/src/context/connection_status.rs @@ -330,7 +330,8 @@ impl ConnectionStatus { } // Update DAPI endpoint status - if let Ok(sdk) = app_context.sdk.read() { + { + let sdk = app_context.sdk.load(); let address_list = sdk.address_list(); let total = address_list.len() as u16; // get_live_address() returns Option<&Uri>, so count it as 1 if available, 0 if not diff --git a/src/context/mod.rs b/src/context/mod.rs index 0abf915fd..aec9533b5 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -20,6 +20,7 @@ use crate::model::wallet::{Wallet, WalletSeedHash}; use crate::sdk_wrapper::initialize_sdk; use crate::spv::{CoreBackendMode, SpvManager}; use crate::utils::tasks::TaskManager; +use arc_swap::ArcSwap; use connection_status::ConnectionStatus; use crossbeam_channel::{Receiver, Sender}; use dash_sdk::Sdk; @@ -54,7 +55,7 @@ pub struct AppContext { #[allow(dead_code)] // May be used for devnet identification pub(crate) devnet_name: Option, pub(crate) db: Arc, - pub(crate) sdk: RwLock, + pub(crate) sdk: ArcSwap, // Context providers for SDK, so we can switch when backend mode changes spv_context_provider: RwLock, rpc_context_provider: RwLock, @@ -245,7 +246,7 @@ impl AppContext { developer_mode: AtomicBool::new(developer_mode_enabled), devnet_name: None, db, - sdk: sdk.into(), + sdk: ArcSwap::from_pointee(sdk), spv_context_provider: spv_provider.into(), rpc_context_provider: rpc_provider.into(), config: config_lock, @@ -303,13 +304,6 @@ impl AppContext { } } else { // Ensure SDK uses the SPV provider - let sdk_lock = match app_context.sdk.write() { - Ok(lock) => lock, - Err(_) => { - tracing::error!("SDK lock poisoned"); - return None; - } - }; let provider = match app_context.spv_context_provider.read() { Ok(p) => p.clone(), Err(_) => { @@ -317,7 +311,7 @@ impl AppContext { return None; } }; - sdk_lock.set_context_provider(provider); + app_context.sdk.load().set_context_provider(provider); } app_context.bootstrap_loaded_wallets(); @@ -363,23 +357,7 @@ impl AppContext { // Switch SDK context provider to match the selected backend match mode { CoreBackendMode::Spv => { - // Make sure SPV provider knows about the app context - if let Err(e) = self - .spv_context_provider - .read() - .map_err(|_| "SPV provider lock poisoned".to_string()) - .and_then(|provider| provider.bind_app_context(Arc::clone(self))) - { - tracing::error!("Failed to bind SPV provider: {}", e); - return; - } - let sdk = match self.sdk.write() { - Ok(lock) => lock, - Err(_) => { - tracing::error!("SDK lock poisoned in set_core_backend_mode"); - return; - } - }; + // Clone the SPV provider and bind app context on the clone let provider = match self.spv_context_provider.read() { Ok(p) => p.clone(), Err(_) => { @@ -387,7 +365,11 @@ impl AppContext { return; } }; - sdk.set_context_provider(provider); + if let Err(e) = provider.bind_app_context(Arc::clone(self)) { + tracing::error!("Failed to bind SPV provider: {}", e); + return; + } + self.sdk.load().set_context_provider(provider); } CoreBackendMode::Rpc => { // RPC provider binding also sets itself on the SDK @@ -512,13 +494,7 @@ impl AppContext { .map_err(|_| "Core client lock poisoned".to_string())?; *client_lock = new_client; } - { - let mut sdk_lock = self - .sdk - .write() - .map_err(|_| "SDK lock poisoned".to_string())?; - *sdk_lock = new_sdk; - } + self.sdk.store(Arc::new(new_sdk)); // Rebind providers to ensure they hold the new AppContext reference self.spv_context_provider @@ -531,16 +507,12 @@ impl AppContext { .map_err(|_| "RPC provider lock poisoned".to_string())? .bind_app_context(self.clone())?; } else { - let sdk_lock = self - .sdk - .write() - .map_err(|_| "SDK lock poisoned".to_string())?; let provider = self .spv_context_provider .read() .map_err(|_| "SPV provider lock poisoned".to_string())? .clone(); - sdk_lock.set_context_provider(provider); + self.sdk.load().set_context_provider(provider); } Ok(()) @@ -549,7 +521,7 @@ impl AppContext { /// Returns the default platform version for the given network. pub(crate) const fn default_platform_version(network: &Network) -> &'static PlatformVersion { - // TODO: Use self.sdk.read().unwrap().version() instead of hardcoding + // TODO: Ideally use sdk.load().version() but this is a free function with no sdk access match network { Network::Dash => &PLATFORM_V11, Network::Testnet => &PLATFORM_V11, diff --git a/src/context/wallet_lifecycle.rs b/src/context/wallet_lifecycle.rs index 8d1117bdd..537cb5689 100644 --- a/src/context/wallet_lifecycle.rs +++ b/src/context/wallet_lifecycle.rs @@ -478,10 +478,7 @@ impl AppContext { return Ok(()); } - let sdk = { - let guard = self.sdk.read().map_err(|_| "SDK lock poisoned")?; - guard.clone() - }; + let sdk = self.sdk.load().as_ref().clone(); for txid in pending_txids { match get_transaction_info(&sdk, &txid).await { diff --git a/src/context_provider.rs b/src/context_provider.rs index 65e931466..76df9c10b 100644 --- a/src/context_provider.rs +++ b/src/context_provider.rs @@ -70,11 +70,7 @@ impl Provider { ac.replace(cloned); drop(ac); - let sdk = app_context - .sdk - .write() - .map_err(|_| "SDK lock poisoned".to_string())?; - sdk.set_context_provider(self.clone()); + app_context.sdk.load().set_context_provider(self.clone()); Ok(()) } } diff --git a/src/ui/wallets/wallets_screen/dialogs.rs b/src/ui/wallets/wallets_screen/dialogs.rs index 701fff7f7..d48eebc57 100644 --- a/src/ui/wallets/wallets_screen/dialogs.rs +++ b/src/ui/wallets/wallets_screen/dialogs.rs @@ -17,7 +17,7 @@ use eframe::egui::{self, ComboBox, Context}; use eframe::epaint::TextureHandle; use egui::Color32; use egui::load::SizedTexture; -use egui::{Frame, Margin, RichText, TextureOptions}; +use egui::{Color32, Frame, Margin, RichText, TextureOptions}; use std::sync::{Arc, RwLock}; use super::WalletsBalancesScreen; From d9ddbfd696871e788bd4eb559878e3f3fd0be115 Mon Sep 17 00:00:00 2001 From: lklimek <842586+lklimek@users.noreply.github.com> Date: Wed, 18 Feb 2026 14:07:17 +0100 Subject: [PATCH 07/46] refactor: remove unused Insight API and show_in_ui config fields (#597) * refactor: remove unused Insight API references The `insight_api_url` field in `NetworkConfig` and its associated `insight_api_uri()` method were never used in production code (both marked `#[allow(dead_code)]`). Remove the field, method, config entries, env example lines, and related tests. https://claude.ai/code/session_01HWPmCJHT8KTZGP9bFiksjn * refactor: remove unused `show_in_ui` field from NetworkConfig The `show_in_ui` field was defined on `NetworkConfig` and serialized in `save()`, but never read by any production code to control network visibility. Remove the field, its serialization, env example lines, and test references. https://claude.ai/code/session_01HWPmCJHT8KTZGP9bFiksjn * fix: add missing `Color32` import in wallet dialogs https://claude.ai/code/session_01HWPmCJHT8KTZGP9bFiksjn --------- Co-authored-by: Claude --- .env.example | 8 -- src/config.rs | 222 ++++------------------------- src/spv/tests.rs | 2 - src/ui/tokens/tokens_screen/mod.rs | 4 - 4 files changed, 27 insertions(+), 209 deletions(-) diff --git a/.env.example b/.env.example index 9e49c25c6..c1d9b0a75 100644 --- a/.env.example +++ b/.env.example @@ -7,27 +7,21 @@ MAINNET_core_host=127.0.0.1 MAINNET_core_rpc_port=9998 MAINNET_core_rpc_user=dashrpc MAINNET_core_rpc_password=password -MAINNET_insight_api_url=https://insight.dash.org/insight-api MAINNET_core_zmq_endpoint=tcp://127.0.0.1:23708 -MAINNET_show_in_ui=true TESTNET_dapi_addresses=https://34.214.48.68:1443,https://52.12.176.90:1443,https://52.34.144.50:1443,https://44.240.98.102:1443,https://54.201.32.131:1443,https://52.10.229.11:1443,https://52.13.132.146:1443,https://52.40.219.41:1443,https://54.149.33.167:1443,https://35.164.23.245:1443,https://52.33.28.47:1443,https://52.43.13.92:1443,https://52.89.154.48:1443,https://52.24.124.162:1443,https://35.85.21.179:1443,https://54.187.14.232:1443,https://54.68.235.201:1443,https://52.13.250.182:1443 TESTNET_core_host=127.0.0.1 TESTNET_core_rpc_port=19998 TESTNET_core_rpc_user=dashrpc TESTNET_core_rpc_password=password -TESTNET_insight_api_url=https://testnet-insight.dash.org/insight-api TESTNET_core_zmq_endpoint=tcp://127.0.0.1:23709 -TESTNET_show_in_ui=true DEVNET_dapi_addresses=http://54.203.116.91:1443,http://52.88.16.46:1443,http://34.222.214.170:1443,http://54.214.77.108:1443,http://52.40.186.234:1443,http://54.202.231.20:1443,http://35.89.246.86:1443,http://18.246.227.21:1443,http://44.245.96.164:1443,http://44.247.160.52:1443 DEVNET_core_host=127.0.0.1 DEVNET_core_rpc_port=29998 DEVNET_core_rpc_user=dashrpc DEVNET_core_rpc_password=password -DEVNET_insight_api_url= DEVNET_core_zmq_endpoint=tcp://127.0.0.1:23710 -DEVNET_show_in_ui=true LOCAL_dapi_addresses=http://127.0.0.1:2443,http://127.0.0.1:2543,http://127.0.0.1:2643 LOCAL_core_host=127.0.0.1 @@ -36,6 +30,4 @@ LOCAL_core_rpc_user=dashmate # Use dashmate cli to retrive it: # dashmate config get core.rpc.users.dashmate.password --config=local_seed LOCAL_core_rpc_password=password -LOCAL_insight_api_url=http://localhost:3001/insight-api LOCAL_core_zmq_endpoint=tcp://127.0.0.1:50298 -LOCAL_show_in_ui=true \ No newline at end of file diff --git a/src/config.rs b/src/config.rs index 7d829acc7..287fb4a73 100644 --- a/src/config.rs +++ b/src/config.rs @@ -4,7 +4,6 @@ use std::str::FromStr; use crate::app_dir::app_user_data_file_path; use dash_sdk::dapi_client::AddressList; use dash_sdk::dpp::dashcore::Network; -use dash_sdk::sdk::Uri; use serde::Deserialize; use tempfile::NamedTempFile; @@ -38,16 +37,12 @@ pub struct NetworkConfig { pub core_rpc_user: String, /// Password for Dash Core RPC interface pub core_rpc_password: String, - /// URL of the Insight API - pub insight_api_url: String, /// ZMQ endpoint for Core blockchain events (e.g., tcp://127.0.0.1:23708) pub core_zmq_endpoint: Option, /// Devnet network name if one exists pub devnet_name: Option, /// Optional wallet private key to instantiate the wallet pub wallet_private_key: Option, - /// Should this network be visible in the UI - pub show_in_ui: bool, } impl Config { @@ -108,13 +103,6 @@ impl Config { prefix, config.core_rpc_password ) .map_err(|e| ConfigError::LoadError(e.to_string()))?; - writeln!( - env_file, - "{}insight_api_url={}", - prefix, config.insight_api_url - ) - .map_err(|e| ConfigError::LoadError(e.to_string()))?; - if let Some(core_zmq_endpoint) = &config.core_zmq_endpoint { writeln!( env_file, @@ -138,10 +126,6 @@ impl Config { .map_err(|e| ConfigError::LoadError(e.to_string()))?; } - // Whether or not to show in UI - writeln!(env_file, "{}show_in_ui={}", prefix, config.show_in_ui) - .map_err(|e| ConfigError::LoadError(e.to_string()))?; - // Add a blank line after each config block writeln!(env_file).map_err(|e| ConfigError::LoadError(e.to_string()))?; @@ -315,7 +299,6 @@ impl NetworkConfig { && !self.core_rpc_password.is_empty() && self.core_rpc_port != 0 && !self.dapi_addresses.is_empty() - && Uri::from_str(&self.insight_api_url).is_ok() } /// List of DAPI addresses @@ -323,12 +306,6 @@ impl NetworkConfig { AddressList::from_str(&self.dapi_addresses).expect("Could not parse DAPI addresses") } - /// Insight API URI - #[allow(dead_code)] // May be used for insight API access - pub fn insight_api_uri(&self) -> Uri { - Uri::from_str(&self.insight_api_url).expect("invalid insight API URL") - } - /// Update just the `core_rpc_password` in a builder-like manner. /// Returns a new `NetworkConfig` with the updated password. pub fn update_core_rpc_password(mut self, new_password: String) -> Self { @@ -342,22 +319,16 @@ mod tests { use super::*; /// Helper to create a minimal valid NetworkConfig for testing - fn make_network_config( - dapi_addresses: &str, - insight_api_url: &str, - port: u16, - ) -> NetworkConfig { + fn make_network_config(dapi_addresses: &str, port: u16) -> NetworkConfig { NetworkConfig { dapi_addresses: dapi_addresses.to_string(), core_host: "127.0.0.1".to_string(), core_rpc_port: port, core_rpc_user: "dashrpc".to_string(), core_rpc_password: "password".to_string(), - insight_api_url: insight_api_url.to_string(), core_zmq_endpoint: Some("tcp://127.0.0.1:23708".to_string()), devnet_name: None, wallet_private_key: None, - show_in_ui: true, } } @@ -365,55 +336,33 @@ mod tests { #[test] fn test_is_valid_with_all_fields() { - let config = make_network_config( - "https://127.0.0.1:443", - "https://insight.dash.org/insight-api", - 9998, - ); + let config = make_network_config("https://127.0.0.1:443", 9998); assert!(config.is_valid()); } #[test] fn test_is_valid_empty_user() { - let mut config = make_network_config( - "https://127.0.0.1:443", - "https://insight.dash.org/insight-api", - 9998, - ); + let mut config = make_network_config("https://127.0.0.1:443", 9998); config.core_rpc_user = String::new(); assert!(!config.is_valid()); } #[test] fn test_is_valid_empty_password() { - let mut config = make_network_config( - "https://127.0.0.1:443", - "https://insight.dash.org/insight-api", - 9998, - ); + let mut config = make_network_config("https://127.0.0.1:443", 9998); config.core_rpc_password = String::new(); assert!(!config.is_valid()); } #[test] fn test_is_valid_zero_port() { - let config = make_network_config( - "https://127.0.0.1:443", - "https://insight.dash.org/insight-api", - 0, - ); + let config = make_network_config("https://127.0.0.1:443", 0); assert!(!config.is_valid()); } #[test] fn test_is_valid_empty_dapi_addresses() { - let config = make_network_config("", "https://insight.dash.org/insight-api", 9998); - assert!(!config.is_valid()); - } - - #[test] - fn test_is_valid_invalid_insight_url() { - let config = make_network_config("https://127.0.0.1:443", "not a valid url \x00", 9998); + let config = make_network_config("", 9998); assert!(!config.is_valid()); } @@ -421,11 +370,7 @@ mod tests { #[test] fn test_dapi_address_list_single_address() { - let config = make_network_config( - "https://127.0.0.1:443", - "https://insight.dash.org/insight-api", - 9998, - ); + let config = make_network_config("https://127.0.0.1:443", 9998); let list = config.dapi_address_list(); assert_eq!(list.len(), 1); } @@ -434,7 +379,6 @@ mod tests { fn test_dapi_address_list_multiple_addresses() { let config = make_network_config( "https://127.0.0.1:443,https://192.168.1.1:443,https://10.0.0.1:443", - "https://insight.dash.org/insight-api", 9998, ); let list = config.dapi_address_list(); @@ -444,46 +388,15 @@ mod tests { #[test] #[should_panic(expected = "Could not parse DAPI addresses")] fn test_dapi_address_list_empty_panics() { - let config = make_network_config("", "https://insight.dash.org/insight-api", 9998); + let config = make_network_config("", 9998); let _list = config.dapi_address_list(); } - // ── NetworkConfig::insight_api_uri ─────────────────────────────── - - #[test] - fn test_insight_api_uri_valid() { - let config = make_network_config( - "https://127.0.0.1:443", - "https://insight.dash.org/insight-api", - 9998, - ); - let uri = config.insight_api_uri(); - assert_eq!(uri.to_string(), "https://insight.dash.org/insight-api"); - } - - #[test] - #[should_panic(expected = "invalid insight API URL")] - fn test_insight_api_uri_invalid_panics() { - let config = make_network_config("https://127.0.0.1:443", "not a valid url \x00", 9998); - let _uri = config.insight_api_uri(); - } - - #[test] - #[should_panic(expected = "invalid insight API URL")] - fn test_insight_api_uri_empty_panics() { - let config = make_network_config("https://127.0.0.1:443", "", 9998); - let _uri = config.insight_api_uri(); - } - // ── NetworkConfig::update_core_rpc_password ───────────────────── #[test] fn test_update_core_rpc_password() { - let config = make_network_config( - "https://127.0.0.1:443", - "https://insight.dash.org/insight-api", - 9998, - ); + let config = make_network_config("https://127.0.0.1:443", 9998); assert_eq!(config.core_rpc_password, "password"); let updated = config.update_core_rpc_password("new_secret".to_string()); assert_eq!(updated.core_rpc_password, "new_secret"); @@ -496,11 +409,7 @@ mod tests { #[test] fn test_config_for_network_mainnet() { - let mainnet_cfg = make_network_config( - "https://127.0.0.1:443", - "https://insight.dash.org/insight-api", - 9998, - ); + let mainnet_cfg = make_network_config("https://127.0.0.1:443", 9998); let config = Config { mainnet_config: Some(mainnet_cfg), testnet_config: None, @@ -517,26 +426,10 @@ mod tests { #[test] fn test_config_for_network_all_networks() { let config = Config { - mainnet_config: Some(make_network_config( - "https://1.1.1.1:443", - "https://main.example.com", - 9998, - )), - testnet_config: Some(make_network_config( - "https://2.2.2.2:1443", - "https://test.example.com", - 19998, - )), - devnet_config: Some(make_network_config( - "http://3.3.3.3:1443", - "http://dev.example.com", - 29998, - )), - local_config: Some(make_network_config( - "http://127.0.0.1:2443", - "http://localhost:3001", - 20302, - )), + mainnet_config: Some(make_network_config("https://1.1.1.1:443", 9998)), + testnet_config: Some(make_network_config("https://2.2.2.2:1443", 19998)), + devnet_config: Some(make_network_config("http://3.3.3.3:1443", 29998)), + local_config: Some(make_network_config("http://127.0.0.1:2443", 20302)), developer_mode: Some(true), }; let main = config.config_for_network(Network::Dash).as_ref().unwrap(); @@ -567,11 +460,7 @@ mod tests { developer_mode: None, }; assert!(config.mainnet_config.is_none()); - let new_cfg = make_network_config( - "https://1.1.1.1:443", - "https://insight.dash.org/insight-api", - 9998, - ); + let new_cfg = make_network_config("https://1.1.1.1:443", 9998); config.update_config_for_network(Network::Dash, new_cfg); assert!(config.mainnet_config.is_some()); assert_eq!(config.mainnet_config.as_ref().unwrap().core_rpc_port, 9998); @@ -580,21 +469,13 @@ mod tests { #[test] fn test_update_config_replaces_existing() { let mut config = Config { - mainnet_config: Some(make_network_config( - "https://old.example.com:443", - "https://old.example.com", - 1111, - )), + mainnet_config: Some(make_network_config("https://old.example.com:443", 1111)), testnet_config: None, devnet_config: None, local_config: None, developer_mode: None, }; - let new_cfg = make_network_config( - "https://new.example.com:443", - "https://new.example.com", - 2222, - ); + let new_cfg = make_network_config("https://new.example.com:443", 2222); config.update_config_for_network(Network::Dash, new_cfg); let main = config.mainnet_config.as_ref().unwrap(); assert_eq!(main.core_rpc_port, 2222); @@ -612,15 +493,15 @@ mod tests { }; config.update_config_for_network( Network::Testnet, - make_network_config("https://t.example.com:1443", "https://t.example.com", 19998), + make_network_config("https://t.example.com:1443", 19998), ); config.update_config_for_network( Network::Devnet, - make_network_config("http://d.example.com:1443", "http://d.example.com", 29998), + make_network_config("http://d.example.com:1443", 29998), ); config.update_config_for_network( Network::Regtest, - make_network_config("http://127.0.0.1:2443", "http://localhost:3001", 20302), + make_network_config("http://127.0.0.1:2443", 20302), ); assert!(config.testnet_config.is_some()); assert!(config.devnet_config.is_some()); @@ -631,11 +512,7 @@ mod tests { #[test] fn test_network_config_optional_fields() { - let mut config = make_network_config( - "https://127.0.0.1:443", - "https://insight.dash.org/insight-api", - 9998, - ); + let mut config = make_network_config("https://127.0.0.1:443", 9998); // Defaults assert!(config.devnet_name.is_none()); assert!(config.wallet_private_key.is_none()); @@ -658,16 +535,8 @@ mod tests { // We can't easily test save() directly since it depends on app_user_data_file_path, // but we can verify the save format by manually constructing what save() would write. let config = Config { - mainnet_config: Some(make_network_config( - "https://1.1.1.1:443", - "https://insight.dash.org/insight-api", - 9998, - )), - testnet_config: Some(make_network_config( - "https://2.2.2.2:1443", - "https://testnet-insight.dash.org/insight-api", - 19998, - )), + mainnet_config: Some(make_network_config("https://1.1.1.1:443", 9998)), + testnet_config: Some(make_network_config("https://2.2.2.2:1443", 19998)), devnet_config: None, local_config: None, developer_mode: Some(true), @@ -684,23 +553,16 @@ mod tests { "MAINNET_core_rpc_password={}\n", cfg.core_rpc_password )); - output.push_str(&format!( - "MAINNET_insight_api_url={}\n", - cfg.insight_api_url - )); if let Some(ref zmq) = cfg.core_zmq_endpoint { output.push_str(&format!("MAINNET_core_zmq_endpoint={}\n", zmq)); } - output.push_str(&format!("MAINNET_show_in_ui={}\n", cfg.show_in_ui)); } assert!(output.contains("MAINNET_dapi_addresses=https://1.1.1.1:443")); assert!(output.contains("MAINNET_core_rpc_port=9998")); assert!(output.contains("MAINNET_core_rpc_user=dashrpc")); assert!(output.contains("MAINNET_core_rpc_password=password")); - assert!(output.contains("MAINNET_insight_api_url=https://insight.dash.org/insight-api")); assert!(output.contains("MAINNET_core_zmq_endpoint=tcp://127.0.0.1:23708")); - assert!(output.contains("MAINNET_show_in_ui=true")); } // ── envy parsing roundtrip ────────────────────────────────────── @@ -720,15 +582,10 @@ mod tests { env_map.insert("TEST_RT_core_rpc_port".into(), "9998".into()); env_map.insert("TEST_RT_core_rpc_user".into(), "testuser".into()); env_map.insert("TEST_RT_core_rpc_password".into(), "testpass".into()); - env_map.insert( - "TEST_RT_insight_api_url".into(), - "https://insight.example.com/api".into(), - ); env_map.insert( "TEST_RT_core_zmq_endpoint".into(), "tcp://127.0.0.1:29999".into(), ); - env_map.insert("TEST_RT_show_in_ui".into(), "true".into()); // Use envy's from_iter to parse from our map (same as from_env but testable) let result: Result = envy::prefixed("TEST_RT_") @@ -740,12 +597,10 @@ mod tests { assert_eq!(config.core_rpc_port, 9998); assert_eq!(config.core_rpc_user, "testuser"); assert_eq!(config.core_rpc_password, "testpass"); - assert_eq!(config.insight_api_url, "https://insight.example.com/api"); assert_eq!( config.core_zmq_endpoint, Some("tcp://127.0.0.1:29999".to_string()) ); - assert!(config.show_in_ui); assert!(config.devnet_name.is_none()); assert!(config.wallet_private_key.is_none()); } @@ -760,8 +615,6 @@ mod tests { env_map.insert("OPT_core_rpc_port".into(), "29998".into()); env_map.insert("OPT_core_rpc_user".into(), "user".into()); env_map.insert("OPT_core_rpc_password".into(), "pass".into()); - env_map.insert("OPT_insight_api_url".into(), "http://localhost:3001".into()); - env_map.insert("OPT_show_in_ui".into(), "false".into()); env_map.insert("OPT_devnet_name".into(), "devnet-evo".into()); env_map.insert("OPT_wallet_private_key".into(), "cVBZ1234abcd".into()); @@ -771,7 +624,6 @@ mod tests { let config = result.unwrap(); assert_eq!(config.devnet_name, Some("devnet-evo".to_string())); assert_eq!(config.wallet_private_key, Some("cVBZ1234abcd".to_string())); - assert!(!config.show_in_ui); assert!(config.core_zmq_endpoint.is_none()); } @@ -786,8 +638,6 @@ mod tests { // core_rpc_port intentionally missing env_map.insert("MISS_core_rpc_user".into(), "user".into()); env_map.insert("MISS_core_rpc_password".into(), "pass".into()); - env_map.insert("MISS_insight_api_url".into(), "http://localhost".into()); - env_map.insert("MISS_show_in_ui".into(), "true".into()); let result: Result = envy::prefixed("MISS_").from_iter(env_map.iter().map(|(k, v)| (k.clone(), v.clone()))); @@ -804,8 +654,6 @@ mod tests { env_map.insert("BAD_core_rpc_port".into(), "not_a_number".into()); env_map.insert("BAD_core_rpc_user".into(), "user".into()); env_map.insert("BAD_core_rpc_password".into(), "pass".into()); - env_map.insert("BAD_insight_api_url".into(), "http://localhost".into()); - env_map.insert("BAD_show_in_ui".into(), "true".into()); let result: Result = envy::prefixed("BAD_").from_iter(env_map.iter().map(|(k, v)| (k.clone(), v.clone()))); @@ -817,11 +665,7 @@ mod tests { #[test] fn test_config_developer_mode() { let config = Config { - mainnet_config: Some(make_network_config( - "https://1.1.1.1:443", - "https://insight.dash.org", - 9998, - )), + mainnet_config: Some(make_network_config("https://1.1.1.1:443", 9998)), testnet_config: None, devnet_config: None, local_config: None, @@ -830,11 +674,7 @@ mod tests { assert_eq!(config.developer_mode, Some(true)); let config_off = Config { - mainnet_config: Some(make_network_config( - "https://1.1.1.1:443", - "https://insight.dash.org", - 9998, - )), + mainnet_config: Some(make_network_config("https://1.1.1.1:443", 9998)), testnet_config: None, devnet_config: None, local_config: None, @@ -843,11 +683,7 @@ mod tests { assert_eq!(config_off.developer_mode, Some(false)); let config_none = Config { - mainnet_config: Some(make_network_config( - "https://1.1.1.1:443", - "https://insight.dash.org", - 9998, - )), + mainnet_config: Some(make_network_config("https://1.1.1.1:443", 9998)), testnet_config: None, devnet_config: None, local_config: None, @@ -861,11 +697,7 @@ mod tests { #[test] fn test_config_clone() { let config = Config { - mainnet_config: Some(make_network_config( - "https://1.1.1.1:443", - "https://insight.dash.org", - 9998, - )), + mainnet_config: Some(make_network_config("https://1.1.1.1:443", 9998)), testnet_config: None, devnet_config: None, local_config: None, diff --git a/src/spv/tests.rs b/src/spv/tests.rs index 776a756e0..823a4c45c 100644 --- a/src/spv/tests.rs +++ b/src/spv/tests.rs @@ -20,11 +20,9 @@ fn test_network_config() -> NetworkConfig { core_rpc_port: 19998, core_rpc_user: "dashrpc".to_string(), core_rpc_password: "password".to_string(), - insight_api_url: "https://testnet-insight.dash.org/insight-api".to_string(), core_zmq_endpoint: Some("tcp://127.0.0.1:23709".to_string()), devnet_name: None, wallet_private_key: None, - show_in_ui: true, } } diff --git a/src/ui/tokens/tokens_screen/mod.rs b/src/ui/tokens/tokens_screen/mod.rs index 5eee677c7..4c5f737a9 100644 --- a/src/ui/tokens/tokens_screen/mod.rs +++ b/src/ui/tokens/tokens_screen/mod.rs @@ -3201,16 +3201,12 @@ mod tests { std::env::set_var("MAINNET_core_rpc_port", "9998"); std::env::set_var("MAINNET_core_rpc_user", "dashrpc"); std::env::set_var("MAINNET_core_rpc_password", "password"); - std::env::set_var("MAINNET_insight_api_url", "http://127.0.0.1:3001"); - std::env::set_var("MAINNET_show_in_ui", "true"); std::env::set_var("LOCAL_dapi_addresses", "http://127.0.0.1:2443"); std::env::set_var("LOCAL_core_host", "127.0.0.1"); std::env::set_var("LOCAL_core_rpc_port", "20302"); std::env::set_var("LOCAL_core_rpc_user", "dashmate"); std::env::set_var("LOCAL_core_rpc_password", "password"); - std::env::set_var("LOCAL_insight_api_url", "http://127.0.0.1:3001"); - std::env::set_var("LOCAL_show_in_ui", "true"); } }); } From b7bc9680e6fa13a5ec02f44f2387523ca8e4067b Mon Sep 17 00:00:00 2001 From: lklimek <842586+lklimek@users.noreply.github.com> Date: Wed, 18 Feb 2026 18:17:43 +0100 Subject: [PATCH 08/46] build: add Flatpak packaging and CI workflow (#589) * build: remove snap version * build: add Flatpak packaging and CI workflow Add Flatpak build manifest, desktop entry, AppStream metadata, and GitHub Actions workflow for building and distributing Flatpak bundles. Uses freedesktop 25.08 runtime with rust-stable and llvm21 extensions. No application source code changes required - works in SPV mode by default. Co-Authored-By: Claude Opus 4.6 * build: address review findings for Flatpak packaging - Pin GitHub Actions to commit SHAs for supply chain security - Upgrade softprops/action-gh-release from v1 to v2.2.2 - Remove redundant --socket=x11 (fallback-x11 suffices) - Remove duplicate tag trigger preventing double builds on release - Remove duplicate env vars inherited from top-level build-options - Add Flatpak build artifacts to .gitignore - Add bugtracker URL to AppStream metainfo - Remove deprecated from metainfo (use .desktop instead) - Add Terminal=false and Keywords to desktop entry - Add disk space check after SDK install in CI - Rename artifact to include architecture suffix Co-Authored-By: Claude Opus 4.6 * build: simplify CI workflows for Linux-only releases - Remove "Free disk space" step from flatpak and release workflows - Remove Windows and macOS builds from release workflow - Use "ubuntu" runner image instead of pinned versions - Clean up unused matrix.ext references Co-Authored-By: Claude Opus 4.6 * build: attach to existing releases instead of creating new ones - Replace release-creating job with attach-to-release (only on release event) - Add 14-day retention for build artifacts - On tag push or workflow_dispatch, only upload artifacts (no release) Co-Authored-By: Claude Opus 4.6 * revert: restore release.yml to original v1.0-dev version The release workflow changes were out of scope for the Flatpak PR. Co-Authored-By: Claude Opus 4.6 * fix: address CodeRabbit review comments - Fix CRLF line endings in Flatpak manifest (convert to LF) - Set app_id on ViewportBuilder to match desktop StartupWMClass - Use --locked flag for reproducible cargo builds in Flatpak - Rename --repo=repo to --repo=flatpak-repo to match .gitignore - Add architecture note for protoc x86_64 binary Co-Authored-By: Claude Opus 4.6 * docs: add Flatpak install instructions to README Add a dedicated section for installing via Flatpak on Linux, clarify that prerequisites are only needed for building from source, and rename "Installation" to "Build from Source" for clarity. Co-Authored-By: Claude Opus 4.6 * fix: match StartupWMClass to Flatpak app_id Use reverse-DNS format org.dash.DashEvoTool to match the Wayland app_id set via ViewportBuilder::with_app_id(). Co-Authored-By: Claude Opus 4.6 * fix: use ** glob for branch trigger to match feat/flatpak Single * doesn't match path separators in GitHub Actions branch filters. Co-Authored-By: Claude Opus 4.6 * feat: add aarch64 Flatpak build and caching to CI - Add matrix strategy for parallel x86_64 and aarch64 builds - Patch protoc URL/sha256 per architecture at build time - Cache .flatpak-builder directory keyed on arch + manifest + lockfile - Pin actions/cache to SHA Co-Authored-By: Claude Opus 4.6 * fix: convert desktop and metainfo files to LF line endings Flatpak builder validates desktop files and rejects CRLF line endings. Co-Authored-By: Claude Opus 4.6 * build: cancel in-progress Flatpak builds on new push Add concurrency group keyed on git ref so a new push cancels any running build for the same branch or release. Co-Authored-By: Claude Opus 4.6 * fix: address PR review findings for Flatpak packaging - Remove unnecessary --filesystem=xdg-config/dash-evo-tool:create (Flatpak already redirects XDG_CONFIG_HOME to sandbox) - Add categories and keywords to AppStream metainfo for discoverability - Update README with both x86_64/aarch64 install commands, uninstall instructions, and Flatpak data path note - Clarify aarch64 comment in manifest to reference CI sed patching Co-Authored-By: Claude Opus 4.6 * chore: workflow timeout and perms * fix: move permissions to job level in Flatpak workflow Step-level permissions are not valid in GitHub Actions. Move contents: write to the job level where it is needed for release attachment. Co-Authored-By: Claude Opus 4.6 * build: cache Cargo registry and target in Flatpak CI Bind-mount host-side cargo-cache and cargo-target directories into the Flatpak build sandbox so CARGO_HOME and target/ persist across builds. Uses split restore/save with cleanup of incremental and registry/src (similar to Swatinem/rust-cache). Co-Authored-By: Claude Opus 4.6 * fix: scope cargo cache bind-mount sed to build-args only The previous sed matched --share=network in both finish-args and build-args, corrupting finish-args. Use a sed range to only target the build-args section. Co-Authored-By: Claude Opus 4.6 * Apply suggestions from code review --------- Co-authored-by: Claude Opus 4.6 --- .github/workflows/flatpak.yml | 132 +++++++++++++ .gitignore | 4 + README.md | 37 +++- flatpak/org.dash.DashEvoTool.desktop | 10 + flatpak/org.dash.DashEvoTool.metainfo.xml | 49 +++++ flatpak/org.dash.DashEvoTool.yml | 74 +++++++ snap/snapcraft.yaml | 226 ---------------------- src/main.rs | 4 +- 8 files changed, 306 insertions(+), 230 deletions(-) create mode 100644 .github/workflows/flatpak.yml create mode 100644 flatpak/org.dash.DashEvoTool.desktop create mode 100644 flatpak/org.dash.DashEvoTool.metainfo.xml create mode 100644 flatpak/org.dash.DashEvoTool.yml delete mode 100644 snap/snapcraft.yaml diff --git a/.github/workflows/flatpak.yml b/.github/workflows/flatpak.yml new file mode 100644 index 000000000..c082bd4d9 --- /dev/null +++ b/.github/workflows/flatpak.yml @@ -0,0 +1,132 @@ +name: Build Flatpak + +on: + push: + branches: + - "**flatpak**" + release: + types: + - published + workflow_dispatch: + +concurrency: + group: flatpak-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + build-flatpak: + name: Build Flatpak (${{ matrix.arch }}) + permissions: + contents: write + strategy: + matrix: + include: + - arch: x86_64 + runs-on: ubuntu-latest + protoc-zip: protoc-25.2-linux-x86_64.zip + protoc-sha256: 78ab9c3288919bdaa6cfcec6127a04813cf8a0ce406afa625e48e816abee2878 + - arch: aarch64 + runs-on: ubuntu-24.04-arm + protoc-zip: protoc-25.2-linux-aarch_64.zip + protoc-sha256: 07683afc764e4efa3fa969d5f049fbc2bdfc6b4e7786a0b233413ac0d8753f6b + + runs-on: ${{ matrix.runs-on }} + timeout-minutes: 20 + + steps: + - name: Check out code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Install Flatpak and Flatpak Builder + run: | + sudo apt-get update + sudo apt-get install -y flatpak flatpak-builder + + - name: Add Flathub remote + run: | + sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo + + - name: Install Flatpak SDK and runtime + run: | + sudo flatpak install -y --noninteractive flathub \ + org.freedesktop.Platform//25.08 \ + org.freedesktop.Sdk//25.08 \ + org.freedesktop.Sdk.Extension.rust-stable//25.08 \ + org.freedesktop.Sdk.Extension.llvm21//25.08 + echo "=== Disk space after SDK install ===" + df -h + + - name: Patch manifest for architecture + run: | + sed -i "s|protoc-25.2-linux-x86_64.zip|${{ matrix.protoc-zip }}|g" flatpak/org.dash.DashEvoTool.yml + sed -i "s|sha256: 78ab9c3288919bdaa6cfcec6127a04813cf8a0ce406afa625e48e816abee2878|sha256: ${{ matrix.protoc-sha256 }}|" flatpak/org.dash.DashEvoTool.yml + + - name: Prepare Cargo cache + run: mkdir -p cargo-cache/registry cargo-cache/git cargo-target + + - name: Restore Cargo cache + uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: | + cargo-cache + cargo-target + key: cargo-flatpak-${{ matrix.arch }}-${{ hashFiles('Cargo.lock') }} + restore-keys: | + cargo-flatpak-${{ matrix.arch }}- + + - name: Bind-mount Cargo cache into Flatpak sandbox + run: | + WORKSPACE="$(pwd)" + sed -i "/^ build-args:$/,/--share=network/{ + /--share=network/a\\ + - --bind-mount=/run/build/dash-evo-tool/cargo=${WORKSPACE}/cargo-cache\\ + - --bind-mount=/run/build/dash-evo-tool/target=${WORKSPACE}/cargo-target + }" flatpak/org.dash.DashEvoTool.yml + + - name: Cache Flatpak builder + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: .flatpak-builder + key: flatpak-builder-${{ matrix.arch }}-${{ hashFiles('flatpak/org.dash.DashEvoTool.yml', 'Cargo.lock') }} + restore-keys: | + flatpak-builder-${{ matrix.arch }}- + + - name: Build Flatpak + run: | + flatpak-builder --force-clean --repo=flatpak-repo build-dir flatpak/org.dash.DashEvoTool.yml + + - name: Create Flatpak bundle + run: | + flatpak build-bundle flatpak-repo dash-evo-tool-linux-${{ matrix.arch }}.flatpak org.dash.DashEvoTool + + - name: Clean Cargo cache for storage + run: | + rm -rf cargo-target/release/incremental + rm -rf cargo-cache/registry/src + echo "=== Cargo cache sizes ===" + du -sh cargo-cache cargo-target || true + + - name: Save Cargo cache + uses: actions/cache/save@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: | + cargo-cache + cargo-target + key: cargo-flatpak-${{ matrix.arch }}-${{ hashFiles('Cargo.lock') }} + + - name: Upload Flatpak bundle as artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: dash-evo-tool-linux-${{ matrix.arch }}-flatpak + path: dash-evo-tool-linux-${{ matrix.arch }}.flatpak + + - name: Attach bundle to release + if: github.event_name == 'release' + uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2.2.2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + files: dash-evo-tool-linux-${{ matrix.arch }}.flatpak diff --git a/.gitignore b/.gitignore index f038c0579..60cccf155 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,10 @@ build-test/ # Build artifacts *.snap +*.flatpak +.flatpak-builder/ +flatpak-repo/ +build-dir/ # Dot env file .env diff --git a/README.md b/README.md index 0c9cd47ce..1d3d1d238 100644 --- a/README.md +++ b/README.md @@ -11,12 +11,13 @@ The tool supports both Mainnet and Testnet networks. Check out the [documentatio ## Table of Contents +- [Install via Flatpak (Linux)](#install-via-flatpak-linux) - [Prerequisites](#prerequisites) - [Rust Installation](#rust-installation) - [Dependencies](#dependencies) - [Windows Runtime Dependencies](#windows-runtime-dependencies) - [Dash Core Wallet Setup](#dash-core-wallet-setup) -- [Installation](#installation) +- [Build from Source](#build-from-source) - [Getting Started](#getting-started) - [Start the App](#start-the-app) - [Application directory](#application-directory) @@ -31,8 +32,38 @@ The tool supports both Mainnet and Testnet networks. Check out the [documentatio - [Support](#support) - [Security Note](#security-note) +## Install via Flatpak (Linux) + +The easiest way to run Dash Evo Tool on Linux is via Flatpak. Download the `.flatpak` bundle for your architecture from the [latest release](https://github.com/dashpay/dash-evo-tool/releases) and install it: + +``` shell +# x86_64 +flatpak install dash-evo-tool-linux-x86_64.flatpak + +# aarch64 (ARM) +flatpak install dash-evo-tool-linux-aarch64.flatpak +``` + +To run: + +``` shell +flatpak run org.dash.DashEvoTool +``` + +To uninstall: + +``` shell +flatpak uninstall org.dash.DashEvoTool +``` + +The Flatpak version runs in SPV (light client) mode — no full Dash Core node is required. Application data is stored in `~/.var/app/org.dash.DashEvoTool/config/dash-evo-tool/`. + +> **Note:** The Flatpak data path differs from native Linux builds, which use `~/.config/dash-evo-tool/`. + ## Prerequisites +The following prerequisites are only needed if you want to build from source. + Before you begin, ensure you have met the following requirements: ### Rust Installation @@ -82,9 +113,9 @@ If you use the prebuilt Windows binary, make sure the target machine has: - **Synchronize Wallet**: Ensure the wallet is fully synced with the network you intend to use (Mainnet or Testnet). -## Installation +## Build from Source -To install Dash Evo Tool: +To build Dash Evo Tool from source: 1. **Clone the repository**: diff --git a/flatpak/org.dash.DashEvoTool.desktop b/flatpak/org.dash.DashEvoTool.desktop new file mode 100644 index 000000000..98b91e1ce --- /dev/null +++ b/flatpak/org.dash.DashEvoTool.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Name=Dash Evo Tool +Comment=Graphical user interface for interacting with Dash Evolution +Exec=dash-evo-tool +Icon=org.dash.DashEvoTool +Type=Application +Terminal=false +Categories=Finance;Network;Utility; +Keywords=Dash;Blockchain;Crypto;Wallet;DPNS;Evolution; +StartupWMClass=org.dash.DashEvoTool diff --git a/flatpak/org.dash.DashEvoTool.metainfo.xml b/flatpak/org.dash.DashEvoTool.metainfo.xml new file mode 100644 index 000000000..ca5049c72 --- /dev/null +++ b/flatpak/org.dash.DashEvoTool.metainfo.xml @@ -0,0 +1,49 @@ + + + org.dash.DashEvoTool + Dash Evo Tool + Graphical user interface for interacting with Dash Platform + + MIT + MIT + + +

+ A cross-platform desktop application for interacting with Dash Platform + (Layer 2). Features include DPNS username registration, contest voting, + state transition viewing, wallet management, and identity operations. + Works in SPV (light client) mode - no full Dash Core node required. +

+
+ + + Finance + Network + BlockchainTools + + + + Dash + cryptocurrency + blockchain + wallet + DPNS + + + https://github.com/dashpay/dash-evo-tool + https://github.com/dashpay/dash-evo-tool/issues + + org.dash.DashEvoTool.desktop + + org.dash.DashEvoTool + + + + + Dash Core Group + + + + + +
diff --git a/flatpak/org.dash.DashEvoTool.yml b/flatpak/org.dash.DashEvoTool.yml new file mode 100644 index 000000000..10583c21a --- /dev/null +++ b/flatpak/org.dash.DashEvoTool.yml @@ -0,0 +1,74 @@ +# Flatpak manifest for Dash Evo Tool +# Build with: flatpak-builder --user --force-clean --repo=flatpak-repo build-dir flatpak/org.dash.DashEvoTool.yml +# Install locally: flatpak-builder --user --install --force-clean build-dir flatpak/org.dash.DashEvoTool.yml +# Create bundle: flatpak build-bundle flatpak-repo dash-evo-tool.flatpak org.dash.DashEvoTool + +app-id: org.dash.DashEvoTool +runtime: org.freedesktop.Platform +runtime-version: '25.08' +sdk: org.freedesktop.Sdk +sdk-extensions: + - org.freedesktop.Sdk.Extension.rust-stable + - org.freedesktop.Sdk.Extension.llvm21 + +command: dash-evo-tool + +finish-args: + # X11 shared memory for display + - --share=ipc + # Network access for blockchain communication + - --share=network + # Display server support (prefer Wayland, fall back to X11) + - --socket=wayland + - --socket=fallback-x11 + # GPU acceleration for egui rendering + - --device=dri + +build-options: + append-path: /usr/lib/sdk/rust-stable/bin:/usr/lib/sdk/llvm21/bin + build-args: + - --share=network + env: + CARGO_HOME: /run/build/dash-evo-tool/cargo + PROTOC: /app/bin/protoc + PROTOC_INCLUDE: /app/include + +modules: + # Module 1: Install protobuf compiler (required for building dash-sdk) + # NOTE: CI patches this module for aarch64 via sed (see .github/workflows/flatpak.yml). + - name: protoc + buildsystem: simple + sources: + - type: file + url: https://github.com/protocolbuffers/protobuf/releases/download/v25.2/protoc-25.2-linux-x86_64.zip + sha256: 78ab9c3288919bdaa6cfcec6127a04813cf8a0ce406afa625e48e816abee2878 + build-commands: + - unzip protoc-25.2-linux-x86_64.zip bin/protoc -d /app + - unzip protoc-25.2-linux-x86_64.zip 'include/*' -d /app + - chmod 755 /app/bin/protoc + + # Module 2: Build libsodium (required for crypto operations) + - name: libsodium + buildsystem: autotools + sources: + - type: archive + url: https://github.com/jedisct1/libsodium/releases/download/1.0.20-RELEASE/libsodium-1.0.20.tar.gz + sha256: ebb65ef6ca439333c2bb41a0c1990587288da07f6c7fd07cb3a18cc18d30ce19 + + # Module 3: Build dash-evo-tool from source + - name: dash-evo-tool + buildsystem: simple + sources: + - type: dir + path: .. + skip: + - .flatpak-builder + - build-dir + - flatpak-repo + - target + build-commands: + - cargo build --release --locked + - install -Dm755 target/release/dash-evo-tool /app/bin/dash-evo-tool + - install -Dm644 flatpak/org.dash.DashEvoTool.desktop /app/share/applications/org.dash.DashEvoTool.desktop + - install -Dm644 flatpak/org.dash.DashEvoTool.metainfo.xml /app/share/metainfo/org.dash.DashEvoTool.metainfo.xml + - install -Dm644 assets/DET_LOGO.png /app/share/icons/hicolor/512x512/apps/org.dash.DashEvoTool.png diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml deleted file mode 100644 index 41c74408b..000000000 --- a/snap/snapcraft.yaml +++ /dev/null @@ -1,226 +0,0 @@ -name: dash-evo-tool -title: Dash Evo Tool -icon: mac_os/AppIcons/Assets.xcassets/AppIcon.appiconset/512.png -type: app -version: "0.9.0-preview.4" -summary: Graphical user interface for interacting with Dash Evolution -description: | - Dash Evo Tool is a graphical user interface for easily interacting with - Dash Evolution. The current version enables registering DPNS usernames, - viewing active DPNS username contests, voting on active contests, and - decoding state transitions. The tool supports both Mainnet and Testnet networks. - -grade: devel -confinement: strict - -base: core24 - -platforms: - amd64: - arm64: - -apps: - dash-evo-tool: - command: bin/dash-evo-tool - desktop: usr/share/applications/dash-evo-tool.desktop - environment: - XKB_CONFIG_ROOT: $SNAP/usr/share/X11/xkb - plugs: - - home - - network - - network-bind - - desktop - - desktop-legacy - - wayland - - x11 - - opengl - - gsettings - - screen-inhibit-control - - browser-support - - process-control # Required to spawn external processes - # - system-files # Required to access system binaries - -parts: - rust-deps: - plugin: nil - build-packages: - - curl - - build-essential - - pkg-config - - libssl-dev - override-build: | - # Install Rust - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain 1.92 - export PATH="$HOME/.cargo/bin:$PATH" - rustc --version - cargo --version - - protoc: - plugin: nil - build-packages: - - curl - - unzip - override-build: | - # Install protoc to stage directory (build-time only) - PROTOC_VERSION="31.1" - if [ "$CRAFT_ARCH_BUILD_ON" = "arm64" ]; then - PROTOC_ARCH="aarch_64" - else - PROTOC_ARCH="x86_64" - fi - curl -OL "https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOC_VERSION}/protoc-${PROTOC_VERSION}-linux-${PROTOC_ARCH}.zip" - unzip -o "protoc-${PROTOC_VERSION}-linux-${PROTOC_ARCH}.zip" -d $SNAPCRAFT_STAGE bin/protoc - unzip -o "protoc-${PROTOC_VERSION}-linux-${PROTOC_ARCH}.zip" -d $SNAPCRAFT_STAGE 'include/*' - chmod +x $SNAPCRAFT_STAGE/bin/protoc - - dash-evo-tool: - after: [rust-deps, protoc] - plugin: nil - source: . - build-packages: - - build-essential - - pkg-config - - clang - - cmake - - libsqlite3-dev - - libxcb-xfixes0-dev - - libxcb-shape0-dev - - libxcb-randr0-dev - - libxcb-xkb-dev - - libxkbcommon-x11-dev - - libxkbcommon-dev - - libssl-dev - - libsodium-dev - - libbsd-dev - - libc6-dev - - libclang-dev - - xkb-data - stage-packages: - - xkb-data - - libxkbcommon0 - - libxkbcommon-x11-0 - - libxcb-xfixes0 - - libxcb-shape0 - - libxcb-randr0 - - libxcb-xkb1 - - libssl3 - - libsodium23 - - libbsd0 - - libsqlite3-0 - - libfontconfig1 - - libfreetype6 - - libx11-6 - - libx11-xcb1 - - libxcursor1 - - libxi6 - - libxrandr2 - - libgl1-mesa-dri - - libglu1-mesa - - libwayland-client0 - - libwayland-cursor0 - - libwayland-egl1 - - mesa-utils - override-build: | - # Setup environment - export PATH="$HOME/.cargo/bin:$PATH" - export PROTOC="$SNAPCRAFT_STAGE/bin/protoc" - - # Create necessary directories and copy files - mkdir -p dash-evo-tool/ - cp .env.example dash-evo-tool/.env - cp -r dash_core_configs/ dash-evo-tool/dash_core_configs - - # Build the project - cargo build --release - - # Install binary - mkdir -p $SNAPCRAFT_PART_INSTALL/bin - cp target/release/dash-evo-tool $SNAPCRAFT_PART_INSTALL/bin/ - - # Install configuration - mkdir -p $SNAPCRAFT_PART_INSTALL/etc/dash-evo-tool - cp -r dash_core_configs/ $SNAPCRAFT_PART_INSTALL/etc/dash-evo-tool/ - cp .env.example $SNAPCRAFT_PART_INSTALL/etc/dash-evo-tool/.env - - # Create desktop file - mkdir -p $SNAPCRAFT_PART_INSTALL/usr/share/applications - cat > $SNAPCRAFT_PART_INSTALL/usr/share/applications/dash-evo-tool.desktop << EOF - [Desktop Entry] - Name=Dash Evo Tool - Comment=Graphical user interface for interacting with Dash Evolution - Exec=dash-evo-tool - Icon=\${SNAP}/usr/share/icons/hicolor/256x256/apps/dash-evo-tool.png - Type=Application - Categories=Utility;Finance;Network; - StartupWMClass=dash-evo-tool - X-SnapInstanceName=dash-evo-tool - EOF - - # Install icon - mkdir -p $SNAPCRAFT_PART_INSTALL/usr/share/icons/hicolor/256x256/apps - if [ -f "mac_os/AppIcons/Assets.xcassets/AppIcon.appiconset/256.png" ]; then - cp mac_os/AppIcons/Assets.xcassets/AppIcon.appiconset/256.png $SNAPCRAFT_PART_INSTALL/usr/share/icons/hicolor/256x256/apps/dash-evo-tool.png - else - # Create a placeholder icon if the original doesn't exist - convert -size 256x256 xc:lightblue -gravity center -pointsize 24 -annotate +0+0 "Dash" $SNAPCRAFT_PART_INSTALL/usr/share/icons/hicolor/256x256/apps/dash-evo-tool.png - fi - - # Include dash-qt in the snap package - dash-qt: - plugin: nil - build-packages: - - curl - - unzip - stage-packages: - # XCB libraries for X11 support - - libxcb-icccm4 - - libxcb-image0 - - libxcb-shm0 - - libxcb-keysyms1 - - libxcb-randr0 - - libxcb-render-util0 - - libxcb-render0 - - libxcb-shape0 - - libxcb-sync1 - - libxcb-xfixes0 - - libxcb-xinerama0 - - libxcb-xkb1 - - libxcb1 - - libxcb-util1 - # X11 authentication - - libxau6 - - libxdmcp6 - # Font and graphics support - - libfontconfig1 - - libfreetype6 - - libexpat1 - - libpng16-16 - # BSD libraries - - libbsd0 - - libmd0 - # XKB support - - libxkbcommon0 - - libxkbcommon-x11-0 - # Core system libraries (usually included but ensure they're available) - - libgcc-s1 - - libc6 - override-build: | - # Download and install dash-qt - DASH_VERSION="22.1.2" - if [ "$CRAFT_ARCH_BUILD_FOR" = "arm64" ]; then - DASH_ARCH="aarch64" - else - DASH_ARCH="x86_64" - fi - - # Download from official Dash releases - curl -L "https://github.com/dashpay/dash/releases/download/v${DASH_VERSION}/dashcore-${DASH_VERSION}-${DASH_ARCH}-linux-gnu.tar.gz" -o dash.tar.gz - tar -xzf dash.tar.gz - - # Install binaries - mkdir -p $SNAPCRAFT_PART_INSTALL/bin $SNAPCRAFT_PART_INSTALL/lib - cp dashcore-${DASH_VERSION}/bin/dash-qt $SNAPCRAFT_PART_INSTALL/bin/ - cp dashcore-${DASH_VERSION}/lib/* $SNAPCRAFT_PART_INSTALL/lib/ - - # Make executable - chmod +x $SNAPCRAFT_PART_INSTALL/bin/* diff --git a/src/main.rs b/src/main.rs index 0d0121cb0..2b364f62d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -52,7 +52,9 @@ async fn start(app_data_dir: &std::path::Path) -> Result<(), eframe::Error> { persist_window: true, // Persist window size and position centered: true, // Center window on startup if not maximized persistence_path: Some(app_data_dir.join("app.ron")), - viewport: egui::ViewportBuilder::default().with_icon(icon_data), + viewport: egui::ViewportBuilder::default() + .with_icon(icon_data) + .with_app_id("org.dash.DashEvoTool"), ..Default::default() }; From 9bc8ff74503c7b7489cea8361596e1e6296f00a8 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 18 Feb 2026 21:39:34 +0100 Subject: [PATCH 09/46] chore: fix build --- src/ui/wallets/wallets_screen/dialogs.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/ui/wallets/wallets_screen/dialogs.rs b/src/ui/wallets/wallets_screen/dialogs.rs index d48eebc57..ac3359a12 100644 --- a/src/ui/wallets/wallets_screen/dialogs.rs +++ b/src/ui/wallets/wallets_screen/dialogs.rs @@ -15,7 +15,6 @@ use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; use dash_sdk::dpp::key_wallet::bip32::DerivationPath; use eframe::egui::{self, ComboBox, Context}; use eframe::epaint::TextureHandle; -use egui::Color32; use egui::load::SizedTexture; use egui::{Color32, Frame, Margin, RichText, TextureOptions}; use std::sync::{Arc, RwLock}; From 0744c771544ca44afdfe8a98189576930e9a624c Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 18 Feb 2026 20:08:25 +0100 Subject: [PATCH 10/46] chore: use new error handling everywhere - not self reviewed --- src/app.rs | 5 +- src/ui/components/wallet_unlock.rs | 56 ++---- .../add_contracts_screen.rs | 37 +--- .../contracts_documents_screen.rs | 53 ++---- .../document_action_screen.rs | 4 +- .../group_actions_screen.rs | 52 ++---- .../register_contract_screen.rs | 23 ++- .../update_contract_screen.rs | 23 ++- src/ui/dashpay/add_contact_screen.rs | 45 ++--- src/ui/dashpay/contact_details.rs | 36 +--- src/ui/dashpay/contact_info_editor.rs | 26 +-- src/ui/dashpay/contact_profile_viewer.rs | 41 ++--- src/ui/dashpay/contact_requests.rs | 72 +------- src/ui/dashpay/contacts_list.rs | 39 +--- src/ui/dashpay/profile_screen.rs | 30 +-- src/ui/dashpay/profile_search.rs | 23 +-- src/ui/dashpay/qr_code_generator.rs | 52 +++--- src/ui/dashpay/qr_scanner.rs | 77 ++++---- src/ui/dashpay/send_payment.rs | 79 +++----- src/ui/dpns/dpns_contested_names_screen.rs | 59 +----- .../add_existing_identity_screen.rs | 72 ++------ .../by_platform_address.rs | 4 +- .../by_using_unused_asset_lock.rs | 4 +- .../by_using_unused_balance.rs | 4 +- .../by_wallet_qr_code.rs | 7 +- .../identities/add_new_identity_screen/mod.rs | 48 ++--- src/ui/identities/keys/add_key_screen.rs | 118 ++++++------ src/ui/identities/keys/key_info_screen.rs | 124 ++++++------- .../identities/register_dpns_name_screen.rs | 57 +++--- src/ui/identities/transfer_screen.rs | 67 +++---- src/ui/identities/withdraw_screen.rs | 66 +++---- src/ui/tokens/add_token_by_id_screen.rs | 26 +-- src/ui/tokens/burn_tokens_screen.rs | 56 +++--- src/ui/tokens/claim_tokens_screen.rs | 49 ++--- src/ui/tokens/destroy_frozen_funds_screen.rs | 56 +++--- src/ui/tokens/direct_token_purchase_screen.rs | 74 ++++---- src/ui/tokens/freeze_tokens_screen.rs | 70 +++---- src/ui/tokens/pause_tokens_screen.rs | 62 +++---- src/ui/tokens/resume_tokens_screen.rs | 62 +++---- src/ui/tokens/set_token_price_screen.rs | 73 ++++---- .../tokens/tokens_screen/contract_details.rs | 10 +- src/ui/tokens/tokens_screen/keyword_search.rs | 21 +-- src/ui/tokens/tokens_screen/mod.rs | 117 ++++-------- src/ui/tokens/tokens_screen/my_tokens.rs | 41 +++-- src/ui/tokens/tokens_screen/token_creator.rs | 49 ++--- src/ui/tokens/transfer_tokens_screen.rs | 57 +++--- src/ui/tokens/unfreeze_tokens_screen.rs | 70 +++---- src/ui/tokens/update_token_config.rs | 93 +++------- src/ui/tokens/view_token_claims_screen.rs | 34 +--- src/ui/tools/address_balance_screen.rs | 38 +--- src/ui/tools/contract_visualizer_screen.rs | 7 +- src/ui/tools/document_visualizer_screen.rs | 7 +- src/ui/tools/grovestark_screen.rs | 132 ++++++-------- src/ui/tools/masternode_list_diff_screen.rs | 57 +++--- src/ui/tools/platform_info_screen.rs | 36 +--- src/ui/tools/transition_visualizer_screen.rs | 11 +- src/ui/wallets/asset_lock_detail_screen.rs | 60 +----- src/ui/wallets/create_asset_lock_screen.rs | 77 ++------ src/ui/wallets/send_screen.rs | 42 ++--- src/ui/wallets/single_key_send_screen.rs | 74 ++------ src/ui/wallets/wallets_screen/mod.rs | 172 ++++++++---------- 61 files changed, 1147 insertions(+), 1989 deletions(-) diff --git a/src/app.rs b/src/app.rs index b09102ba6..75ed7a9c0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -824,9 +824,8 @@ 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) => { + MessageBanner::set_global(ctx, msg, MessageType::Success); self.visible_screen_mut() .display_task_result(unboxed_message); } diff --git a/src/ui/components/wallet_unlock.rs b/src/ui/components/wallet_unlock.rs index 67b695035..e2f3b2f78 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,15 @@ 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); } } // Clear the password field after submission @@ -125,32 +126,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 f2773351b..d1ea3c1f2 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::MessageBanner; use crate::ui::components::styled::island_central_panel; use crate::ui::components::top_panel::add_top_panel; use crate::ui::theme::DashColors; @@ -11,7 +12,7 @@ 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}; @@ -22,7 +23,7 @@ enum AddContractsStatus { NotStarted, WaitingForResult(TimestampMillis), Complete(Vec), - ErrorMessage(String), + Error, } pub struct AddContractsScreen { @@ -90,7 +91,8 @@ impl AddContractsScreen { ))) } 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 +271,11 @@ 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_contracts_status = AddContractsStatus::Error; } MessageType::Success | MessageType::Info => {} } @@ -324,29 +327,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); diff --git a/src/ui/contracts_documents/contracts_documents_screen.rs b/src/ui/contracts_documents/contracts_documents_screen.rs index 66b1895c8..3cb630b49 100644 --- a/src/ui/contracts_documents/contracts_documents_screen.rs +++ b/src/ui/contracts_documents/contracts_documents_screen.rs @@ -10,11 +10,11 @@ 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::MessageBanner; 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; @@ -47,7 +47,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, @@ -77,7 +76,7 @@ pub enum DocumentQueryStatus { NotStarted, WaitingForResult(TimestampMillis), Complete, - ErrorMessage(String), + Error, } #[derive(PartialEq, Eq, Clone)] @@ -112,7 +111,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()), @@ -137,22 +135,6 @@ impl DocumentQueryScreen { } } - 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(); - } - } - } - fn build_document_query_with_cursor(&self, cursor: &Start) -> DocumentQuery { let mut query = DocumentQuery::new( self.selected_data_contract.contract.clone(), @@ -222,15 +204,12 @@ impl DocumentQueryScreen { ))); } Err(e) => { - self.document_query_status = DocumentQueryStatus::ErrorMessage(format!( - "Failed to parse query properly: {}", - e - )); - self.error_message = Some(( - format!("Failed to parse query properly: {}", e), + self.document_query_status = DocumentQueryStatus::Error; + MessageBanner::set_global( + ui.ctx(), + &format!("Failed to parse query properly: {}", e), MessageType::Error, - Utc::now(), - )); + ); } } } @@ -241,7 +220,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); @@ -366,10 +344,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 @@ -539,7 +515,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,10 +538,11 @@ 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.document_query_status = DocumentQueryStatus::Error; } } @@ -597,7 +573,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)), diff --git a/src/ui/contracts_documents/document_action_screen.rs b/src/ui/contracts_documents/document_action_screen.rs index f6661b06d..343fbf9b1 100644 --- a/src/ui/contracts_documents/document_action_screen.rs +++ b/src/ui/contracts_documents/document_action_screen.rs @@ -1629,8 +1629,8 @@ 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()); + 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. self.broadcast_status = BroadcastStatus::NotBroadcasted; } diff --git a/src/ui/contracts_documents/group_actions_screen.rs b/src/ui/contracts_documents/group_actions_screen.rs index e2977864d..7cdb51d9f 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::MessageBanner; 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; @@ -49,7 +50,7 @@ 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; @@ -61,7 +62,7 @@ enum FetchGroupActionsStatus { NotStarted, WaitingForResult(TimestampMillis), Complete(IndexMap), - ErrorMessage(String), + Error, } /// The screen @@ -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; } }; @@ -451,11 +458,11 @@ 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_group_actions_status = FetchGroupActionsStatus::Error; } MessageType::Success | MessageType::Info => {} } @@ -567,27 +574,8 @@ impl ScreenLike for GroupActionsScreen { } 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) => { diff --git a/src/ui/contracts_documents/register_contract_screen.rs b/src/ui/contracts_documents/register_contract_screen.rs index c565754fe..d6f7ea3ae 100644 --- a/src/ui/contracts_documents/register_contract_screen.rs +++ b/src/ui/contracts_documents/register_contract_screen.rs @@ -6,6 +6,7 @@ use crate::context::AppContext; 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::identity_selector::IdentitySelector; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; @@ -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, } @@ -108,7 +108,6 @@ impl RegisterDataContractScreen { selected_wallet, wallet_unlock_popup: WalletUnlockPopup::new(), - error_message: None, completed_fee_result: None, } } @@ -171,9 +170,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 +206,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 +340,12 @@ impl RegisterDataContractScreen { impl ScreenLike for RegisterDataContractScreen { fn display_message(&mut self, message: &str, message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. if message_type == MessageType::Error { 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; } } } @@ -476,12 +475,16 @@ impl ScreenLike for RegisterDataContractScreen { .cloned(); // Update wallet + let mut wallet_error = None; self.selected_wallet = get_selected_wallet( identity, Some(&self.app_context), None, - &mut self.error_message, + &mut wallet_error, ); + if let Some(e) = wallet_error { + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + } // Re-parse contract with new owner ID self.parse_contract(); @@ -524,7 +527,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 e5c12fdcf..dcc5e8c3b 100644 --- a/src/ui/contracts_documents/update_contract_screen.rs +++ b/src/ui/contracts_documents/update_contract_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::components::MessageBanner; use crate::ui::components::identity_selector::IdentitySelector; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; @@ -39,7 +40,7 @@ enum BroadcastStatus { FetchingNonce(u64), Broadcasting(u64), ProofError(u64), - BroadcastError(String), + BroadcastError, Done, } @@ -58,7 +59,6 @@ pub struct UpdateDataContractScreen { pub selected_wallet: Option>>, wallet_unlock_popup: WalletUnlockPopup, - error_message: Option, completed_fee_result: Option, } @@ -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(_) | BroadcastStatus::BroadcastError => { + // Parsing errors shown via render_error_bubble; broadcast errors via global banner } BroadcastStatus::ValidContract(contract) => { // Fee estimation display - contract updates charge registration fees for the new contract @@ -362,12 +361,12 @@ impl UpdateDataContractScreen { impl ScreenLike for UpdateDataContractScreen { fn display_message(&mut self, message: &str, message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. if message_type == MessageType::Error { 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; } } } @@ -494,12 +493,16 @@ impl ScreenLike for UpdateDataContractScreen { .cloned(); // Update wallet + let mut wallet_error = None; self.selected_wallet = get_selected_wallet( identity, Some(&self.app_context), None, - &mut self.error_message, + &mut wallet_error, ); + if let Some(e) = wallet_error { + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + } // Re-parse contract with new owner ID self.parse_contract(); @@ -542,7 +545,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..769686be9 100644 --- a/src/ui/dashpay/add_contact_screen.rs +++ b/src/ui/dashpay/add_contact_screen.rs @@ -45,7 +45,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 +61,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 +77,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 +94,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 +103,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 +113,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 +142,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 +183,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 +227,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 @@ -520,7 +498,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 +557,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,7 +599,7 @@ impl ScreenLike for AddContactScreen { } fn display_message(&mut self, message: &str, message_type: MessageType) { - self.message = Some((message.to_string(), message_type)); + // Banner display is handled globally by AppState; this is only for side-effects. if message_type == MessageType::Error { let error = DashPayError::Internal { message: message.to_string(), @@ -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 5ca9f8e50..36aad3595 100644 --- a/src/ui/dashpay/contact_details.rs +++ b/src/ui/dashpay/contact_details.rs @@ -52,7 +52,6 @@ pub struct ContactDetailsScreen { edit_nickname: String, edit_note: String, edit_hidden: bool, - message: Option<(String, MessageType)>, loading: bool, show_info_popup: bool, } @@ -73,7 +72,6 @@ impl ContactDetailsScreen { edit_nickname: String::new(), edit_note: String::new(), edit_hidden: false, - message: None, loading: false, show_info_popup: false, }; @@ -88,7 +86,6 @@ impl ContactDetailsScreen { // Clear any existing data - real data should be loaded from backend when needed self.contact_info = None; self.payment_history.clear(); - self.message = None; // TODO: Implement real backend fetching of contact info and payment history // This should be triggered by user actions or specific backend tasks @@ -120,7 +117,12 @@ impl ContactDetailsScreen { } self.editing_info = false; - self.display_message("Contact info updated", MessageType::Success); + + crate::ui::components::MessageBanner::set_global( + self.app_context.egui_ctx(), + "Contact info updated", + MessageType::Success, + ); } fn cancel_editing(&mut self) { @@ -143,18 +145,6 @@ impl ContactDetailsScreen { 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, - MessageType::Info => egui::Color32::LIGHT_BLUE, - }; - ui.colored_label(color, message); - ui.separator(); - } - // Loading indicator if self.loading { ui.horizontal(|ui| { @@ -370,18 +360,12 @@ impl ContactDetailsScreen { ui.horizontal(|ui| { if ui.button("Remove Contact").clicked() { // TODO: Implement contact removal - self.display_message( - "Contact removal not yet implemented", - MessageType::Info, - ); + crate::ui::components::MessageBanner::set_global(ui.ctx(), "Contact removal not yet implemented", MessageType::Info); } if ui.button("Block Contact").clicked() { // TODO: Implement contact blocking - self.display_message( - "Contact blocking not yet implemented", - MessageType::Info, - ); + crate::ui::components::MessageBanner::set_global(ui.ctx(), "Contact blocking not yet implemented", MessageType::Info); } }); }); @@ -403,8 +387,8 @@ impl ContactDetailsScreen { 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. } } diff --git a/src/ui/dashpay/contact_info_editor.rs b/src/ui/dashpay/contact_info_editor.rs index d2b0d74d5..7912e9cfb 100644 --- a/src/ui/dashpay/contact_info_editor.rs +++ b/src/ui/dashpay/contact_info_editor.rs @@ -37,7 +37,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>>, @@ -65,7 +64,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 +133,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 +233,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)); + crate::ui::components::MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } wallet_needs_unlock(wallet) } else { @@ -298,21 +284,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 e6fcbac1b..b3dbe10ca 100644 --- a/src/ui/dashpay/contact_profile_viewer.rs +++ b/src/ui/dashpay/contact_profile_viewer.rs @@ -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(), @@ -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(( - format!("Failed to save: {}", e), + 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 9f6dfecb7..f3d169b4b 100644 --- a/src/ui/dashpay/contact_requests.rs +++ b/src/ui/dashpay/contact_requests.rs @@ -51,7 +51,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)>, @@ -73,7 +72,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, @@ -134,7 +132,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; // Load requests from database for the newly selected identity @@ -229,7 +226,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(), @@ -258,7 +254,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 @@ -292,10 +287,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 { @@ -317,10 +308,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 @@ -367,7 +354,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; // Update wallet for the newly selected identity @@ -430,21 +416,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; @@ -513,13 +484,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| { @@ -626,7 +591,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 { @@ -698,13 +663,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| { @@ -789,10 +748,7 @@ impl ContactRequests { |ui| { if ui.button("Cancel").clicked() { // TODO: Cancel outgoing request - self.display_message( - "Request cancelled", - MessageType::Info, - ); + crate::ui::components::MessageBanner::set_global(ui.ctx(), "Request cancelled", MessageType::Info); } }, ); @@ -841,23 +797,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 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) { @@ -987,30 +937,22 @@ 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(), - 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)); } 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 5192991ab..110a951d3 100644 --- a/src/ui/dashpay/contacts_list.rs +++ b/src/ui/dashpay/contacts_list.rs @@ -60,7 +60,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, @@ -82,7 +81,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, @@ -176,7 +174,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(), @@ -204,7 +201,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 @@ -320,7 +316,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 @@ -530,18 +525,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| { @@ -924,10 +907,10 @@ impl ContactsList { new_hidden, ) { - self.message = Some(( - format!("Failed to update contact: {}", e), - MessageType::Error, - )); + tracing::error!( + "Failed to update contact: {}", + e + ); } else { // Update the contact in memory if let Some(c) = @@ -978,8 +961,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. } } @@ -999,9 +982,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) { @@ -1030,9 +1013,8 @@ impl ScreenLike for ContactsList { 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 @@ -1118,9 +1100,8 @@ impl ScreenLike for ContactsList { } } - // 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 diff --git a/src/ui/dashpay/profile_screen.rs b/src/ui/dashpay/profile_screen.rs index a97aac44b..542237877 100644 --- a/src/ui/dashpay/profile_screen.rs +++ b/src/ui/dashpay/profile_screen.rs @@ -84,7 +84,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 +116,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, @@ -346,9 +344,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,7 +387,6 @@ impl ProfileScreen { self.editing = true; self.has_unsaved_changes = false; self.validation_errors.clear(); - self.message = None; } fn save_profile(&mut self) -> AppAction { @@ -449,7 +443,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,7 +619,6 @@ 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 @@ -656,18 +648,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 +865,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)); + crate::ui::components::MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } wallet_needs_unlock(wallet) } else { @@ -1348,7 +1328,8 @@ impl ProfileScreen { ui.horizontal(|ui| { if ui.button("Copy URL").clicked() { ui.ctx().copy_text(avatar_url.clone()); - self.display_message( + crate::ui::components::MessageBanner::set_global( + ui.ctx(), "Avatar URL copied to clipboard", MessageType::Info, ); @@ -1394,9 +1375,8 @@ 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 + 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 message_type == MessageType::Error { self.loading = false; self.saving = false; diff --git a/src/ui/dashpay/profile_search.rs b/src/ui/dashpay/profile_search.rs index a22810f4e..71e3851d8 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,7 +45,6 @@ impl ProfileSearchScreen { app_context, search_query: String::new(), search_results: Vec::new(), - message: None, loading: false, has_searched: false, show_info_popup: false, @@ -117,18 +115,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| { @@ -258,9 +244,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 +288,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 +356,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..15bd27a1e 100644 --- a/src/ui/dashpay/qr_code_generator.rs +++ b/src/ui/dashpay/qr_code_generator.rs @@ -42,7 +42,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 +57,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, @@ -90,7 +88,11 @@ impl QRCodeGeneratorScreen { let account_idx = match self.account_index.parse::() { Ok(v) => v, Err(_) => { - self.display_message("Invalid account index number", MessageType::Error); + crate::ui::components::MessageBanner::set_global( + self.app_context.egui_ctx(), + "Invalid account index number", + MessageType::Error, + ); return; } }; @@ -98,7 +100,8 @@ impl QRCodeGeneratorScreen { let validity = match self.validity_hours.parse::() { Ok(v) if v > 0 && v <= 720 => v, // Max 30 days _ => { - self.display_message( + crate::ui::components::MessageBanner::set_global( + self.app_context.egui_ctx(), "Validity hours must be between 1 and 720", MessageType::Error, ); @@ -110,17 +113,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); + crate::ui::components::MessageBanner::set_global( + self.app_context.egui_ctx(), + "QR code generated successfully", + MessageType::Success, + ); } Err(e) => { - self.display_message( + crate::ui::components::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); + crate::ui::components::MessageBanner::set_global( + self.app_context.egui_ctx(), + "Please select an identity first", + MessageType::Error, + ); } } @@ -145,18 +157,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 @@ -213,7 +213,6 @@ impl QRCodeGeneratorScreen { } // Clear generated QR code when identity changes self.generated_qr_data = None; - self.message = None; } }); ui.end_row(); @@ -265,7 +264,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)); + crate::ui::components::MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } wallet_needs_unlock(wallet) } else { @@ -293,7 +292,6 @@ impl QRCodeGeneratorScreen { if self.generated_qr_data.is_some() && ui.button("Clear").clicked() { self.generated_qr_data = None; - self.message = None; } }); } @@ -368,7 +366,7 @@ impl QRCodeGeneratorScreen { } if show_copied_message { - self.display_message("Copied to clipboard", MessageType::Success); + crate::ui::components::MessageBanner::set_global(ui.ctx(), "Copied to clipboard", MessageType::Success); } }); @@ -387,8 +385,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 +433,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..db6ad9716 100644 --- a/src/ui/dashpay/qr_scanner.rs +++ b/src/ui/dashpay/qr_scanner.rs @@ -15,7 +15,6 @@ use crate::ui::components::wallet_unlock_popup::{ }; 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 +28,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 +41,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 +49,30 @@ 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); + crate::ui::components::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); + crate::ui::components::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); + crate::ui::components::MessageBanner::set_global( + self.app_context.egui_ctx(), + &format!("Invalid QR code: {}", e), + MessageType::Error, + ); } } } @@ -84,7 +93,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); + crate::ui::components::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 +119,18 @@ impl QRScannerScreen { return AppAction::BackendTask(task); } else { - self.display_message("Please parse a QR code first", MessageType::Error); + crate::ui::components::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); + crate::ui::components::MessageBanner::set_global( + self.app_context.egui_ctx(), + "Please select an identity", + MessageType::Error, + ); } AppAction::None @@ -117,24 +138,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 @@ -210,7 +218,6 @@ impl QRScannerScreen { if ui.button("Clear").clicked() { self.qr_data_input.clear(); self.parsed_qr_data = None; - self.message = None; } }); }); @@ -248,7 +255,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)); + crate::ui::components::MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } wallet_needs_unlock(wallet) } else { @@ -299,22 +306,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 +360,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 f5e9dd89c..64aff949c 100644 --- a/src/ui/dashpay/send_payment.rs +++ b/src/ui/dashpay/send_payment.rs @@ -42,7 +42,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 +68,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, @@ -88,7 +86,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); + crate::ui::components::MessageBanner::set_global( + self.app_context.egui_ctx(), + "Please enter an amount", + MessageType::Error, + ); return AppAction::None; } @@ -109,7 +111,11 @@ impl SendPaymentScreen { }; if let Err(e) = wallet_check { - self.display_message(&e, MessageType::Error); + crate::ui::components::MessageBanner::set_global( + self.app_context.egui_ctx(), + &e, + MessageType::Error, + ); return AppAction::None; } @@ -117,7 +123,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); + crate::ui::components::MessageBanner::set_global( + self.app_context.egui_ctx(), + &format!("Invalid amount: {}", e), + MessageType::Error, + ); return AppAction::None; } }; @@ -180,20 +190,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(); @@ -204,7 +200,7 @@ impl SendPaymentScreen { }; if let Some(e) = wallet_open_error { - self.display_message(&e, MessageType::Error); + crate::ui::components::MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } if needs_unlock { @@ -354,7 +350,8 @@ impl SendPaymentScreen { if ui.add_enabled(send_enabled, send_button).clicked() { if self.memo.len() > 100 { - self.display_message( + crate::ui::components::MessageBanner::set_global( + ui.ctx(), "Memo must be 100 characters or less", MessageType::Error, ); @@ -374,8 +371,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. } } @@ -437,21 +434,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, - )); } } } @@ -462,7 +454,6 @@ pub struct PaymentHistory { selected_identity: Option, selected_identity_string: String, payments: Vec, - message: Option<(String, MessageType)>, loading: bool, has_searched: bool, } @@ -484,7 +475,6 @@ impl PaymentHistory { selected_identity: None, selected_identity_string: String::new(), payments: Vec::new(), - message: None, loading: false, has_searched: false, }; @@ -572,7 +562,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(), @@ -586,7 +575,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 @@ -647,20 +635,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( @@ -797,8 +771,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) { @@ -873,9 +847,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..7c0385a55 100644 --- a/src/ui/dpns/dpns_contested_names_screen.rs +++ b/src/ui/dpns/dpns_contested_names_screen.rs @@ -18,6 +18,7 @@ use crate::backend_task::identity::IdentityTask; use crate::context::AppContext; use crate::model::contested_name::{ContestState, ContestedName}; use crate::model::qualified_identity::{DPNSNameInfo, QualifiedIdentity}; +use crate::ui::components::MessageBanner; use crate::ui::components::dpns_subscreen_chooser_panel::add_dpns_subscreen_chooser_panel; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::{StyledButton, island_central_panel}; @@ -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 @@ -188,7 +188,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(), @@ -208,23 +207,6 @@ impl DPNSScreen { } } - // --------------------------- - // 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 // --------------------------- @@ -313,7 +295,6 @@ impl DPNSScreen { } else { let now = Utc::now().timestamp() as u64; self.refreshing_status = RefreshingStatus::Refreshing(now); - self.message = None; // Clear any existing message match self.dpns_subscreen { DPNSSubscreen::Active | DPNSSubscreen::Past => { app_action = AppAction::BackendTask(BackendTask::ContestedResourceTask( @@ -977,12 +958,14 @@ impl DPNSScreen { .db .set_identity_alias(&identifier, Some(&alias_with_suffix)) { - self.display_message( + MessageBanner::set_global( + ui.ctx(), &format!("Failed to set alias: {}", e), MessageType::Error, ); } else { - self.display_message( + MessageBanner::set_global( + ui.ctx(), &format!( "Alias set to '{}' for identity {}", alias_with_suffix, @@ -1839,8 +1822,8 @@ impl ScreenLike for DPNSScreen { self.refresh(); } - fn display_message(&mut self, message: &str, message_type: MessageType) { - // Sync error states + fn display_message(&mut self, message: &str, _message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. 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 +1834,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) { @@ -1924,7 +1904,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(); @@ -2109,28 +2088,6 @@ impl ScreenLike for DPNSScreen { 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 } inner_action }); @@ -2143,7 +2100,6 @@ impl ScreenLike for DPNSScreen { )) => { self.refreshing_status = RefreshingStatus::Refreshing(Utc::now().timestamp() as u64); - self.message = None; // Clear any existing message } // If refreshing owned names, set self.refreshing = true AppAction::BackendTask(BackendTask::IdentityTask( @@ -2151,7 +2107,6 @@ impl ScreenLike for DPNSScreen { )) => { self.refreshing_status = RefreshingStatus::Refreshing(Utc::now().timestamp() as u64); - self.message = None; // Clear any existing message } AppAction::SetMainScreen(_) => { 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 699b1a0e5..5828b62b6 100644 --- a/src/ui/identities/add_existing_identity_screen.rs +++ b/src/ui/identities/add_existing_identity_screen.rs @@ -4,6 +4,7 @@ use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::context::AppContext; use crate::model::qualified_identity::IdentityType; use crate::model::wallet::Wallet; +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; @@ -18,7 +19,7 @@ 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; @@ -77,7 +78,7 @@ enum WalletIdentitySearchMode { pub enum AddIdentityStatus { NotStarted, WaitingForResult(TimestampMillis), - ErrorMessage(String), + Error, Complete, } @@ -94,7 +95,6 @@ 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, @@ -128,7 +128,6 @@ impl AddExistingIdentityScreen { selected_wallet, identity_associated_with_wallet: true, wallet_unlock_popup: WalletUnlockPopup::new(), - error_message: None, identity_index_input: String::new(), app_context: app_context.clone(), show_pop_up_info: None, @@ -258,7 +257,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) { @@ -564,7 +563,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) { @@ -602,7 +601,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; } @@ -677,8 +675,12 @@ impl AddExistingIdentityScreen { )); } else { // Handle invalid index input - self.add_identity_status = - AddIdentityStatus::ErrorMessage("Invalid identity index".to_string()); + self.add_identity_status = AddIdentityStatus::Error; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Invalid identity index", + MessageType::Error, + ); } } action @@ -937,7 +939,6 @@ 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; @@ -951,9 +952,11 @@ 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.add_identity_status = AddIdentityStatus::Error; } MessageType::Success => { // Check if this is a final success message or a progress update @@ -1018,28 +1021,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]) @@ -1085,7 +1067,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; } @@ -1144,27 +1125,8 @@ impl ScreenLike for AddExistingIdentityScreen { 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::Error => { + // Error display is handled by the global MessageBanner } AddIdentityStatus::Complete => { // handled above 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 db358b514..4387e776c 100644 --- a/src/ui/identities/add_new_identity_screen/mod.rs +++ b/src/ui/identities/add_new_identity_screen/mod.rs @@ -12,6 +12,7 @@ use crate::backend_task::identity::{ use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; use crate::model::wallet::Wallet; +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; @@ -84,7 +85,6 @@ pub struct AddNewIdentityScreen { alias_input: String, copied_to_clipboard: Option>, identity_keys: IdentityKeys, - error_message: Option, wallet_unlock_popup: WalletUnlockPopup, show_pop_up_info: Option, in_key_selection_advanced_mode: bool, @@ -150,7 +150,6 @@ impl AddNewIdentityScreen { master_private_key_type: KeyType::ECDSA_HASH160, keys_input: vec![], }, - error_message: None, wallet_unlock_popup: WalletUnlockPopup::new(), show_pop_up_info: None, in_key_selection_advanced_mode: false, @@ -825,12 +824,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; } @@ -977,14 +984,12 @@ impl AddNewIdentityScreen { } impl ScreenLike for AddNewIdentityScreen { - fn display_message(&mut self, message: &str, message_type: MessageType) { + fn display_message(&mut self, _message: &str, message_type: MessageType) { + // Error/success display is handled by the global MessageBanner. + // Side-effect only: reset step on error so we stop showing "Waiting for Platform acknowledgement". if message_type == MessageType::Error { - self.error_message = Some(format!("Error registering identity: {}", message)); - // Reset step so we stop showing "Waiting for Platform acknowledgement" let mut step = self.step.write().unwrap(); *step = WalletFundedScreenStep::ReadyToCreate; - } else { - self.error_message = Some(message.to_string()); } } fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { @@ -1065,28 +1070,7 @@ impl ScreenLike for AddNewIdentityScreen { 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 message_color = DashColors::ERROR; - - ui.horizontal(|ui| { - egui::Frame::new() - .fill(message_color.gamma_multiply(0.1)) - .inner_margin(egui::Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, message_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label(egui::RichText::new(&error_message).color(message_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 ScrollArea::vertical().show(ui, |ui| { let step = {*self.step.read().unwrap()}; @@ -1121,7 +1105,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/keys/add_key_screen.rs b/src/ui/identities/keys/add_key_screen.rs index 4b048eca2..56fdcdbca 100644 --- a/src/ui/identities/keys/add_key_screen.rs +++ b/src/ui/identities/keys/add_key_screen.rs @@ -6,6 +6,7 @@ use crate::model::fee_estimation::format_credits_as_dash; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::qualified_identity::qualified_identity_public_key::QualifiedIdentityPublicKey; 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; @@ -35,7 +36,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; pub enum AddKeyStatus { NotStarted, WaitingForResult(TimestampMillis), - ErrorMessage(String), + Error, Complete, } @@ -49,7 +50,6 @@ 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, @@ -66,9 +66,11 @@ 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 mut wallet_error = None; + let selected_wallet = get_selected_wallet(&identity, None, selected_key, &mut wallet_error); + if let Some(e) = wallet_error { + MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error); + } Self { identity, @@ -80,7 +82,6 @@ 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, @@ -101,9 +102,11 @@ 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 mut wallet_error = None; + let selected_wallet = get_selected_wallet(&identity, None, selected_key, &mut wallet_error); + if let Some(e) = wallet_error { + MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error); + } let dashpay_contract_id = app_context .dashpay_contract @@ -120,7 +123,6 @@ 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, @@ -141,9 +143,11 @@ 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 mut wallet_error = None; + let selected_wallet = get_selected_wallet(&identity, None, selected_key, &mut wallet_error); + if let Some(e) = wallet_error { + MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error); + } let dashpay_contract_id = app_context .dashpay_contract @@ -160,7 +164,6 @@ 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, @@ -179,8 +182,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 +205,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 +233,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 +252,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 +292,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, + ); } } @@ -324,9 +350,10 @@ impl ScreenLike for AddKeyScreen { } } - fn display_message(&mut self, message: &str, message_type: MessageType) { + fn display_message(&mut self, _message: &str, message_type: MessageType) { + // Error/success display is handled by the global MessageBanner. if let MessageType::Error = message_type { - self.add_key_status = AddKeyStatus::ErrorMessage(message.to_string()); + self.add_key_status = AddKeyStatus::Error; } } @@ -381,7 +408,7 @@ impl ScreenLike for AddKeyScreen { && 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); @@ -682,25 +709,8 @@ impl ScreenLike for AddKeyScreen { 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::Error => { + // Error display is handled by the global MessageBanner } AddKeyStatus::Complete => { // handled above diff --git a/src/ui/identities/keys/key_info_screen.rs b/src/ui/identities/keys/key_info_screen.rs index 1917ab876..0f0d8aaf5 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/register_dpns_name_screen.rs b/src/ui/identities/register_dpns_name_screen.rs index 6236b47d2..09525777c 100644 --- a/src/ui/identities/register_dpns_name_screen.rs +++ b/src/ui/identities/register_dpns_name_screen.rs @@ -5,6 +5,7 @@ use crate::context::AppContext; 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::identity_selector::IdentitySelector; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; @@ -40,7 +41,7 @@ pub enum RegisterDpnsNameSource { pub enum RegisterDpnsNameStatus { NotStarted, WaitingForResult(TimestampMillis), - ErrorMessage(String), + Error, Complete, } @@ -55,7 +56,6 @@ 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, @@ -69,12 +69,15 @@ 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 mut wallet_error: 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, &mut wallet_error) } else { None }; + if let Some(e) = wallet_error { + MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error); + } // Auto-select a suitable key for DPNS registration // Note: MASTER keys cannot be used for document operations, @@ -118,7 +121,6 @@ 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, @@ -159,8 +161,12 @@ impl RegisterDpnsNameScreen { .cloned(); // Update the selected wallet + let mut wallet_error = None; self.selected_wallet = - get_selected_wallet(qi, Some(&self.app_context), None, &mut self.error_message); + get_selected_wallet(qi, Some(&self.app_context), None, &mut wallet_error); + if let Some(e) = wallet_error { + MessageBanner::set_global(self.app_context.egui_ctx(), &e, MessageType::Error); + } } else { // If not found, you might want to handle this case // For now, we'll set selected_qualified_identity to None @@ -211,12 +217,12 @@ impl RegisterDpnsNameScreen { .cloned(); // Update wallet - self.selected_wallet = get_selected_wallet( - identity, - Some(&self.app_context), - None, - &mut self.error_message, - ); + let mut wallet_error = None; + self.selected_wallet = + get_selected_wallet(identity, Some(&self.app_context), None, &mut wallet_error); + if let Some(e) = wallet_error { + MessageBanner::set_global(self.app_context.egui_ctx(), &e, MessageType::Error); + } } else { self.selected_key = None; self.selected_wallet = None; @@ -294,10 +300,10 @@ impl RegisterDpnsNameScreen { } impl ScreenLike for RegisterDpnsNameScreen { - 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. if let MessageType::Error = message_type { - self.register_dpns_name_status = - RegisterDpnsNameStatus::ErrorMessage(message.to_string()); + self.register_dpns_name_status = RegisterDpnsNameStatus::Error; } } @@ -403,7 +409,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); @@ -588,23 +594,8 @@ impl ScreenLike for RegisterDpnsNameScreen { 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::Error => { + // Error display is handled by the global MessageBanner } RegisterDpnsNameStatus::Complete => {} } diff --git a/src/ui/identities/transfer_screen.rs b/src/ui/identities/transfer_screen.rs index e76903112..75a46f546 100644 --- a/src/ui/identities/transfer_screen.rs +++ b/src/ui/identities/transfer_screen.rs @@ -6,6 +6,7 @@ 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}; @@ -51,7 +52,7 @@ pub enum TransferDestinationType { pub enum TransferCreditsStatus { NotStarted, WaitingForResult(TimestampMillis), - ErrorMessage(String), + Error, Complete, } @@ -63,7 +64,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, @@ -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); + let mut wallet_error = None; + let selected_wallet = get_selected_wallet(&identity, None, selected_key, &mut wallet_error); + if let Some(e) = wallet_error { + MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error); + } 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, @@ -147,7 +148,7 @@ 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::NotStarted | TransferCreditsStatus::Error => { amount_input.set_max_amount(Some(max_amount_credits)); true } @@ -311,9 +312,12 @@ 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; } @@ -363,9 +367,12 @@ 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; } @@ -407,8 +414,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 +494,10 @@ impl TransferScreen { } impl ScreenLike for TransferScreen { - 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. if let MessageType::Error = message_type { - self.transfer_credits_status = TransferCreditsStatus::ErrorMessage(message.to_string()); - self.error_message = Some(message.to_string()); + self.transfer_credits_status = TransferCreditsStatus::Error; } } @@ -589,7 +596,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); @@ -791,26 +798,8 @@ impl ScreenLike for TransferScreen { 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::Error => { + // Error display is handled by the global MessageBanner } TransferCreditsStatus::Complete => { // Handled above diff --git a/src/ui/identities/withdraw_screen.rs b/src/ui/identities/withdraw_screen.rs index 437483e46..499615348 100644 --- a/src/ui/identities/withdraw_screen.rs +++ b/src/ui/identities/withdraw_screen.rs @@ -7,6 +7,7 @@ use crate::model::fee_estimation::format_credits_as_dash; use crate::model::qualified_identity::encrypted_key_storage::PrivateKeyData; use crate::model::qualified_identity::{IdentityType, PrivateKeyTarget, 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::left_panel::add_left_panel; @@ -41,7 +42,7 @@ use super::keys::key_info_screen::KeyInfoScreen; pub enum WithdrawFromIdentityStatus { NotStarted, WaitingForResult(TimestampMillis), - ErrorMessage(String), + Error, Complete, } @@ -58,7 +59,6 @@ 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, @@ -74,9 +74,11 @@ 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 mut wallet_error = None; + let selected_wallet = get_selected_wallet(&identity, None, selected_key, &mut wallet_error); + if let Some(e) = wallet_error { + MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error); + } Self { identity, selected_key: selected_key.cloned(), @@ -90,7 +92,6 @@ 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, } @@ -121,8 +122,7 @@ impl WithdrawalScreen { let enabled = match self.withdraw_from_identity_status { WithdrawFromIdentityStatus::WaitingForResult(_) | WithdrawFromIdentityStatus::Complete => false, - WithdrawFromIdentityStatus::NotStarted - | WithdrawFromIdentityStatus::ErrorMessage(_) => { + WithdrawFromIdentityStatus::NotStarted | WithdrawFromIdentityStatus::Error => { amount_input.set_max_amount(Some(max_amount_credits)); true } @@ -240,8 +240,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 +253,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; }; @@ -318,10 +325,10 @@ impl WithdrawalScreen { } impl ScreenLike for WithdrawalScreen { - 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. if let MessageType::Error = message_type { - self.withdraw_from_identity_status = - WithdrawFromIdentityStatus::ErrorMessage(message.to_string()); + self.withdraw_from_identity_status = WithdrawFromIdentityStatus::Error; } } @@ -472,7 +479,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); @@ -639,32 +646,11 @@ impl ScreenLike for WithdrawalScreen { 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::Error => { + // Error display is handled by the global MessageBanner } WithdrawFromIdentityStatus::Complete => { - ui.colored_label( - egui::Color32::DARK_GREEN, - "Successfully withdrew from identity".to_string(), - ); + // Handled above } } } diff --git a/src/ui/tokens/add_token_by_id_screen.rs b/src/ui/tokens/add_token_by_id_screen.rs index 95d469c42..f2bdc0ba1 100644 --- a/src/ui/tokens/add_token_by_id_screen.rs +++ b/src/ui/tokens/add_token_by_id_screen.rs @@ -21,7 +21,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. @@ -44,7 +44,6 @@ pub struct AddTokenByIdScreen { status: AddTokenStatus, selected_token: Option, - error_message: Option, try_token_id_next: bool, } @@ -56,7 +55,6 @@ impl AddTokenByIdScreen { fetched_contract: None, status: AddTokenStatus::Idle, selected_token: None, - error_message: None, try_token_id_next: false, } } @@ -79,7 +77,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 @@ -250,6 +247,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") { @@ -277,7 +275,6 @@ impl ScreenLike for AddTokenByIdScreen { } else { self.status = AddTokenStatus::Error(msg.to_owned()); } - self.error_message = Some(msg.to_owned()); } MessageType::Info => {} } @@ -324,8 +321,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 +370,13 @@ 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), - ); + if let AddTokenStatus::Error(_) = &self.status { + // Error display is handled by the global MessageBanner } 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 950611c62..ac830e92a 100644 --- a/src/ui/tokens/burn_tokens_screen.rs +++ b/src/ui/tokens/burn_tokens_screen.rs @@ -1,5 +1,6 @@ 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; @@ -49,7 +50,7 @@ use super::tokens_screen::IdentityTokenInfo; pub enum BurnTokensStatus { NotStarted, WaitingForResult(u64), - ErrorMessage(String), + Error, Complete, } @@ -68,7 +69,6 @@ pub struct BurnTokensScreen { pub public_note: Option, status: BurnTokensStatus, - error_message: Option, // Basic references pub app_context: Arc, @@ -108,7 +108,9 @@ impl BurnTokensScreen { ) .cloned(); - let mut error_message = None; + let set_error_banner = |msg: &str| { + MessageBanner::set_global(app_context.egui_ctx(), msg, MessageType::Error); + }; 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,16 @@ impl BurnTokensScreen { }; // 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 error_message, + &mut wallet_error, ); + if let Some(e) = wallet_error { + set_error_banner(&e); + } Self { identity_token_info, @@ -210,7 +214,6 @@ impl BurnTokensScreen { max_amount: token_balance, public_note: None, status: BurnTokensStatus::NotStarted, - error_message, app_context: app_context.clone(), confirmation_dialog: None, selected_wallet, @@ -244,8 +247,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; } @@ -332,10 +339,10 @@ impl BurnTokensScreen { } impl ScreenLike for BurnTokensScreen { - 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. if let MessageType::Error = message_type { - self.status = BurnTokensStatus::ErrorMessage(message.to_string()); - self.error_message = Some(message.to_string()); + self.status = BurnTokensStatus::Error; } } @@ -466,7 +473,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); @@ -660,11 +667,8 @@ impl ScreenLike for BurnTokensScreen { let elapsed = now - start_time; ui.label(format!("Burning... elapsed: {} seconds", elapsed)); } - 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 8be0c5a70..c21500438 100644 --- a/src/ui/tokens/claim_tokens_screen.rs +++ b/src/ui/tokens/claim_tokens_screen.rs @@ -1,6 +1,7 @@ use crate::backend_task::{BackendTaskSuccessResult, FeeResult}; use crate::model::fee_estimation::format_credits_as_dash; use crate::ui::components::Component; +use crate::ui::components::MessageBanner; 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; @@ -44,7 +45,7 @@ use super::tokens_screen::IdentityTokenBasicInfo; pub enum ClaimTokensStatus { NotStarted, WaitingForResult(u64), - ErrorMessage(String), + Error, Complete, } @@ -58,7 +59,6 @@ pub struct ClaimTokensScreen { token_configuration: TokenConfiguration, distribution_type: Option, status: ClaimTokensStatus, - error_message: Option, pub app_context: Arc, confirmation_dialog: Option, selected_wallet: Option>>, @@ -98,9 +98,11 @@ impl ClaimTokensScreen { ); } - let mut error_message = None; - let selected_wallet = - get_selected_wallet(&identity, None, possible_key, &mut error_message); + let mut wallet_error = None; + let selected_wallet = get_selected_wallet(&identity, None, possible_key, &mut wallet_error); + if let Some(e) = wallet_error { + MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error); + } let distribution_type = match ( token_configuration @@ -128,7 +130,6 @@ impl ClaimTokensScreen { token_configuration, distribution_type, status: ClaimTokensStatus::NotStarted, - error_message, app_context: app_context.clone(), confirmation_dialog: None, selected_wallet, @@ -243,10 +244,10 @@ impl ClaimTokensScreen { } impl ScreenLike for ClaimTokensScreen { - 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. if let MessageType::Error = message_type { - self.status = ClaimTokensStatus::ErrorMessage(message.to_string()); - self.error_message = Some(message.to_string()); + self.status = ClaimTokensStatus::Error; } } @@ -355,7 +356,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); @@ -543,8 +544,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() { @@ -571,25 +575,8 @@ impl ScreenLike for ClaimTokensScreen { let elapsed = now - start_time; ui.label(format!("Claiming... elapsed: {}s", elapsed)); } - 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 e632ab69d..2c85e3c8a 100644 --- a/src/ui/tokens/destroy_frozen_funds_screen.rs +++ b/src/ui/tokens/destroy_frozen_funds_screen.rs @@ -6,6 +6,7 @@ use crate::context::AppContext; 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::component_trait::Component; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::identity_selector::IdentitySelector; @@ -44,7 +45,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; pub enum DestroyFrozenFundsStatus { NotStarted, WaitingForResult(u64), - ErrorMessage(String), + Error, Complete, } @@ -76,7 +77,6 @@ pub struct DestroyFrozenFundsScreen { pub frozen_identities: Vec, status: DestroyFrozenFundsStatus, - error_message: Option, /// Basic references pub app_context: Arc, @@ -104,7 +104,9 @@ impl DestroyFrozenFundsScreen { ) .cloned(); - let mut error_message = None; + let set_error_banner = |msg: &str| { + MessageBanner::set_global(app_context.egui_ctx(), msg, MessageType::Error); + }; 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("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 } @@ -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,12 +187,16 @@ impl DestroyFrozenFundsScreen { }; // 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 error_message, + &mut wallet_error, ); + if let Some(e) = wallet_error { + set_error_banner(&e); + } let all_identities = app_context .load_local_qualified_identities() @@ -210,7 +214,6 @@ impl DestroyFrozenFundsScreen { group_action_id: None, public_note: None, status: DestroyFrozenFundsStatus::NotStarted, - error_message, app_context: app_context.clone(), confirmation_dialog: None, selected_wallet, @@ -268,8 +271,12 @@ impl DestroyFrozenFundsScreen { ], ); if maybe_frozen_id.is_err() { - self.error_message = Some("Invalid frozen identity format".into()); - self.status = DestroyFrozenFundsStatus::ErrorMessage("Invalid identity".into()); + self.status = DestroyFrozenFundsStatus::Error; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Invalid frozen identity format", + MessageType::Error, + ); return AppAction::None; } let frozen_id = maybe_frozen_id.unwrap(); @@ -329,10 +336,10 @@ impl DestroyFrozenFundsScreen { } impl ScreenLike for DestroyFrozenFundsScreen { - 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. if let MessageType::Error = message_type { - self.status = DestroyFrozenFundsStatus::ErrorMessage(message.to_string()); - self.error_message = Some(message.to_string()); + self.status = DestroyFrozenFundsStatus::Error; } } @@ -455,7 +462,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); @@ -623,11 +630,8 @@ impl ScreenLike for DestroyFrozenFundsScreen { elapsed )); } - 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 3f715ea52..5db3ce109 100644 --- a/src/ui/tokens/direct_token_purchase_screen.rs +++ b/src/ui/tokens/direct_token_purchase_screen.rs @@ -18,6 +18,7 @@ use crate::context::AppContext; use crate::model::amount::{Amount, DASH_DECIMAL_PLACES}; use crate::model::fee_estimation::format_credits_as_dash; 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::left_panel::add_left_panel; @@ -43,7 +44,7 @@ use dash_sdk::platform::IdentityPublicKey; pub enum PurchaseTokensStatus { NotStarted, WaitingForResult(u64), // Use seconds or millis - ErrorMessage(String), + Error, Complete, } @@ -65,7 +66,6 @@ pub struct PurchaseTokenScreen { /// Screen stuff confirmation_dialog: Option, status: PurchaseTokensStatus, - error_message: Option, // Wallet fields selected_wallet: Option>>, @@ -87,15 +87,18 @@ impl PurchaseTokenScreen { ) .cloned(); - let mut error_message = None; + let mut wallet_error = 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, + &mut wallet_error, ); + if let Some(e) = wallet_error { + MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error); + } Self { identity_token_info, @@ -107,7 +110,6 @@ 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, @@ -160,9 +162,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 +268,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; }; @@ -341,13 +352,11 @@ 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, ); } } @@ -359,10 +368,10 @@ impl ScreenLike for PurchaseTokenScreen { } } - 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. if let MessageType::Error = message_type { - self.status = PurchaseTokensStatus::ErrorMessage(message.to_string()); - self.error_message = Some(message.to_string()); + self.status = PurchaseTokensStatus::Error; } } @@ -471,7 +480,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 +610,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 { @@ -644,11 +653,8 @@ impl ScreenLike for PurchaseTokenScreen { let elapsed = now - start_time; ui.label(format!("Purchasing... elapsed: {} seconds", elapsed)); } - 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 7bf40e67b..f6f0f0051 100644 --- a/src/ui/tokens/freeze_tokens_screen.rs +++ b/src/ui/tokens/freeze_tokens_screen.rs @@ -6,6 +6,7 @@ use crate::context::AppContext; 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::component_trait::Component; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::identity_selector::IdentitySelector; @@ -44,7 +45,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; pub enum FreezeTokensStatus { NotStarted, WaitingForResult(u64), - ErrorMessage(String), + Error, Complete, } @@ -65,7 +66,6 @@ pub struct FreezeTokensScreen { pub freeze_identity_id: String, status: FreezeTokensStatus, - error_message: Option, // Basic references pub app_context: Arc, @@ -97,7 +97,9 @@ impl FreezeTokensScreen { ) .cloned(); - let mut error_message = None; + let set_error_banner = |msg: &str| { + MessageBanner::set_global(app_context.egui_ctx(), msg, MessageType::Error); + }; let group = match identity_token_info .token_config @@ -105,32 +107,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("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 } @@ -142,7 +142,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 +157,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 +180,16 @@ impl FreezeTokensScreen { }; // 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 error_message, + &mut wallet_error, ); + if let Some(e) = wallet_error { + set_error_banner(&e); + } Self { identity: identity_token_info.identity.clone(), @@ -198,7 +202,6 @@ 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, @@ -259,8 +262,12 @@ impl FreezeTokensScreen { ], ); if parsed.is_err() { - self.error_message = Some("Please enter a valid identity ID.".into()); - self.status = FreezeTokensStatus::ErrorMessage("Invalid identity".into()); + self.status = FreezeTokensStatus::Error; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Please enter a valid identity ID.", + MessageType::Error, + ); return AppAction::None; } let freeze_id = parsed.unwrap(); @@ -321,10 +328,10 @@ impl FreezeTokensScreen { } impl ScreenLike for FreezeTokensScreen { - 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. if let MessageType::Error = message_type { - self.status = FreezeTokensStatus::ErrorMessage(message.to_string()); - self.error_message = Some(message.to_string()); + self.status = FreezeTokensStatus::Error; } } @@ -442,7 +449,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); @@ -618,25 +625,8 @@ impl ScreenLike for FreezeTokensScreen { let elapsed = now - start_time; ui.label(format!("Freezing... elapsed: {}s", elapsed)); } - 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/pause_tokens_screen.rs b/src/ui/tokens/pause_tokens_screen.rs index 809c6f080..1e1cd354d 100644 --- a/src/ui/tokens/pause_tokens_screen.rs +++ b/src/ui/tokens/pause_tokens_screen.rs @@ -7,6 +7,7 @@ use crate::model::fee_estimation::format_credits_as_dash; 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::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; @@ -43,7 +44,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; pub enum PauseTokensStatus { NotStarted, WaitingForResult(u64), - ErrorMessage(String), + Error, Complete, } @@ -59,7 +60,6 @@ pub struct PauseTokensScreen { pub public_note: Option, status: PauseTokensStatus, - error_message: Option, // Basic references pub app_context: Arc, @@ -87,7 +87,9 @@ impl PauseTokensScreen { ) .cloned(); - let mut error_message = None; + let set_error_banner = |msg: &str| { + MessageBanner::set_global(app_context.egui_ctx(), msg, MessageType::Error); + }; 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("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 } @@ -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,16 @@ impl PauseTokensScreen { }; // 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 error_message, + &mut wallet_error, ); + if let Some(e) = wallet_error { + set_error_banner(&e); + } Self { identity: identity_token_info.identity.clone(), @@ -187,7 +191,6 @@ impl PauseTokensScreen { group_action_id: None, public_note: None, status: PauseTokensStatus::NotStarted, - error_message, app_context: app_context.clone(), confirmation_dialog: None, selected_wallet, @@ -268,10 +271,10 @@ impl PauseTokensScreen { } impl ScreenLike for PauseTokensScreen { - 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. if let MessageType::Error = message_type { - self.status = PauseTokensStatus::ErrorMessage(message.to_string()); - self.error_message = Some(message.to_string()); + self.status = PauseTokensStatus::Error; } } @@ -387,7 +390,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); @@ -523,25 +526,8 @@ impl ScreenLike for PauseTokensScreen { let elapsed = now - start_time; ui.label(format!("Pausing... elapsed: {}s", elapsed)); } - 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 3f20f299d..9a332207c 100644 --- a/src/ui/tokens/resume_tokens_screen.rs +++ b/src/ui/tokens/resume_tokens_screen.rs @@ -7,6 +7,7 @@ use crate::model::fee_estimation::format_credits_as_dash; 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::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; @@ -43,7 +44,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; pub enum ResumeTokensStatus { NotStarted, WaitingForResult(u64), - ErrorMessage(String), + Error, Complete, } @@ -58,7 +59,6 @@ pub struct ResumeTokensScreen { pub public_note: Option, status: ResumeTokensStatus, - error_message: Option, // Basic references pub app_context: Arc, @@ -86,7 +86,9 @@ impl ResumeTokensScreen { ) .cloned(); - let mut error_message = None; + let set_error_banner = |msg: &str| { + MessageBanner::set_global(app_context.egui_ctx(), msg, MessageType::Error); + }; 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("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 } @@ -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,16 @@ impl ResumeTokensScreen { }; // 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 error_message, + &mut wallet_error, ); + if let Some(e) = wallet_error { + set_error_banner(&e); + } Self { identity: identity_token_info.identity.clone(), @@ -186,7 +190,6 @@ impl ResumeTokensScreen { group_action_id: None, public_note: None, status: ResumeTokensStatus::NotStarted, - error_message, app_context: app_context.clone(), confirmation_dialog: None, selected_wallet, @@ -268,10 +271,10 @@ impl ResumeTokensScreen { } impl ScreenLike for ResumeTokensScreen { - 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. if let MessageType::Error = message_type { - self.status = ResumeTokensStatus::ErrorMessage(message.to_string()); - self.error_message = Some(message.to_string()); + self.status = ResumeTokensStatus::Error; } } @@ -388,7 +391,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); @@ -523,25 +526,8 @@ impl ScreenLike for ResumeTokensScreen { let elapsed = now - start_time; ui.label(format!("Resuming... elapsed: {}s", elapsed)); } - 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 813d9c50f..4e6879508 100644 --- a/src/ui/tokens/set_token_price_screen.rs +++ b/src/ui/tokens/set_token_price_screen.rs @@ -7,6 +7,7 @@ use crate::model::amount::{Amount, DASH_DECIMAL_PLACES}; use crate::model::fee_estimation::format_credits_as_dash; use crate::model::wallet::Wallet; use crate::ui::components::ComponentResponse; +use crate::ui::components::MessageBanner; use crate::ui::components::amount_input::AmountInput; use crate::ui::components::component_trait::Component; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; @@ -76,7 +77,7 @@ impl From> for PricingType { pub enum SetTokenPriceStatus { NotStarted, WaitingForResult(u64), // Use seconds or millis - ErrorMessage(String), + Error, Complete, } @@ -101,7 +102,6 @@ pub struct SetTokenPriceScreen { // Tiered pricing with AmountInput components pub tiered_prices: Vec<(Option, Option)>, // (amount_input, price_input) status: SetTokenPriceStatus, - error_message: Option, /// Basic references pub app_context: Arc, @@ -170,7 +170,9 @@ impl SetTokenPriceScreen { false, ); - let mut error_message = None; + let set_error_banner = |msg: &str| { + MessageBanner::set_global(app_context.egui_ctx(), msg, MessageType::Error); + }; 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,16 @@ impl SetTokenPriceScreen { }; // 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, - &mut error_message, + &mut wallet_error, ); + if let Some(e) = wallet_error { + set_error_banner(&e); + } Self { identity_token_info: identity_token_info.clone(), @@ -277,7 +279,6 @@ 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, @@ -776,8 +777,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,10 +819,10 @@ impl SetTokenPriceScreen { } impl ScreenLike for SetTokenPriceScreen { - 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. if let MessageType::Error = message_type { - self.status = SetTokenPriceStatus::ErrorMessage(message.to_string()); - self.error_message = Some(message.to_string()); + self.status = SetTokenPriceStatus::Error; } } @@ -952,7 +953,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 +1047,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!( @@ -1148,23 +1152,8 @@ impl ScreenLike for SetTokenPriceScreen { let elapsed = now - start_time; ui.label(format!("Setting price... elapsed: {} seconds", elapsed)); } - 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..56cfb7029 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..25603eefd 100644 --- a/src/ui/tokens/tokens_screen/keyword_search.rs +++ b/src/ui/tokens/tokens_screen/keyword_search.rs @@ -9,7 +9,7 @@ use crate::ui::tokens::tokens_screen::{ 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\ @@ -138,23 +138,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..8e2fb8000 100644 --- a/src/ui/tokens/tokens_screen/mod.rs +++ b/src/ui/tokens/tokens_screen/mod.rs @@ -60,6 +60,7 @@ 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; @@ -194,7 +195,7 @@ pub enum ContractSearchStatus { NotStarted, WaitingForResult(u64), Complete, - ErrorMessage(String), + Error, } #[derive(Debug, PartialEq, Default)] @@ -203,7 +204,7 @@ pub enum TokenCreatorStatus { NotStarted, WaitingForResult(u64), Complete, - ErrorMessage(String), + Error, } /// Sorting columns @@ -1167,7 +1168,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 +1255,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, @@ -1564,7 +1563,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 +1595,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(), @@ -1844,19 +1841,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 +2234,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 +2271,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 +2474,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 +2484,22 @@ 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 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())); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Adding token...", + MessageType::Info, + ); // Always save the token locally and refresh balances // The contract will be fetched automatically when needed @@ -2601,11 +2594,11 @@ impl TokensScreen { .app_context .remove_token_balance(token_to_remove.token_id, token_to_remove.identity_id) { - self.backend_message = Some(( - format!("Error removing token balance: {}", e), + MessageBanner::set_global( + self.app_context.egui_ctx(), + &format!("Error removing token balance: {}", e), MessageType::Error, - Utc::now(), - )); + ); } else { self.refresh(); } @@ -2663,11 +2656,11 @@ impl TokensScreen { .db .remove_token(&token_to_remove, &self.app_context) { - self.backend_message = Some(( - format!("Error removing token balance: {}", e), + MessageBanner::set_global( + self.app_context.egui_ctx(), + &format!("Error removing token balance: {}", e), MessageType::Error, - Utc::now(), - )); + ); } else { self.refresh(); } @@ -2801,8 +2794,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,7 +2917,7 @@ impl ScreenLike for TokensScreen { } } - // Show either refreshing indicator or message, but not both + // Show refreshing indicator if let RefreshingStatus::Refreshing(start_time) = self.refreshing_status { ui.add_space(25.0); // Space above let now = Utc::now().timestamp() as u64; @@ -2937,27 +2928,8 @@ impl ScreenLike for TokensScreen { 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 } + // Message display is handled by the global MessageBanner if self.confirm_remove_identity_token_balance_popup { self.show_remove_identity_token_balance_popup(ui); @@ -2986,7 +2958,6 @@ impl ScreenLike for TokensScreen { { self.refreshing_status = RefreshingStatus::Refreshing(Utc::now().timestamp() as u64); - self.backend_message = None; // Clear any existing message } AppAction::SetMainScreenThenGoToMainScreen(_) => { self.refreshing_status = RefreshingStatus::NotRefreshing; @@ -3037,6 +3008,8 @@ impl ScreenLike for TokensScreen { } fn display_message(&mut self, msg: &str, msg_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. + // Reset contract details loading on any error if msg_type == MessageType::Error && self.contract_details_loading { self.contract_details_loading = false; @@ -3044,13 +3017,10 @@ impl ScreenLike for TokensScreen { 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 +3034,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 +3045,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,14 +3053,9 @@ 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(), - )); } } } diff --git a/src/ui/tokens/tokens_screen/my_tokens.rs b/src/ui/tokens/tokens_screen/my_tokens.rs index a97dfdc47..72c176736 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,7 +22,7 @@ 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 crate::ui::{MessageType, Screen, ScreenType}; use chrono::{Local, Utc}; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; @@ -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); + } } } } @@ -647,12 +650,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, + ); } } } @@ -673,7 +682,7 @@ impl TokensScreen { ); } Err(e) => { - self.token_creator_error_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } }; @@ -695,7 +704,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); @@ -716,7 +725,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); @@ -737,7 +746,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); @@ -758,7 +767,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); @@ -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/token_creator.rs b/src/ui/tokens/tokens_screen/token_creator.rs index 208d70a35..9cc58d05e 100644 --- a/src/ui/tokens/tokens_screen/token_creator.rs +++ b/src/ui/tokens/tokens_screen/token_creator.rs @@ -21,6 +21,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; @@ -426,11 +428,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); } } } @@ -869,11 +870,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 +922,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 +932,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); }, } } @@ -968,26 +968,7 @@ impl TokensScreen { }); } - // 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); - } + // Error messages are displayed by the global MessageBanner }); // Close the ScrollArea from line 40 @@ -1003,12 +984,12 @@ 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, - ); + let mut wallet_error = None; + self.selected_wallet = + crate::ui::identities::get_selected_wallet(qid, None, Some(key), &mut wallet_error); + if let Some(e) = wallet_error { + MessageBanner::set_global(self.app_context.egui_ctx(), &e, MessageType::Error); + } } } @@ -1019,7 +1000,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) { @@ -1476,7 +1457,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; } diff --git a/src/ui/tokens/transfer_tokens_screen.rs b/src/ui/tokens/transfer_tokens_screen.rs index ac04bb8f3..c5678f2e1 100644 --- a/src/ui/tokens/transfer_tokens_screen.rs +++ b/src/ui/tokens/transfer_tokens_screen.rs @@ -6,6 +6,7 @@ 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}; @@ -41,7 +42,7 @@ use super::tokens_screen::IdentityTokenBalance; pub enum TransferTokensStatus { NotStarted, WaitingForResult(TimestampMillis), - ErrorMessage(String), + Error, Complete, } @@ -87,9 +88,11 @@ impl TransferTokensScreen { 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 mut wallet_error = None; + let selected_wallet = get_selected_wallet(&identity, None, selected_key, &mut wallet_error); + if let Some(e) = wallet_error { + MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error); + } let amount = Some(Amount::from(&identity_token_balance).with_value(0)); @@ -140,7 +143,7 @@ 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::NotStarted | TransferTokensStatus::Error => { amount_input.set_max_amount(Some(self.max_amount.value())); true } @@ -194,8 +197,12 @@ impl TransferTokensScreen { fn confirmation_ok(&mut self) -> AppAction { 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; } @@ -208,8 +215,12 @@ impl TransferTokensScreen { ); if parsed_receiver_id.is_err() { - self.transfer_tokens_status = - TransferTokensStatus::ErrorMessage("Invalid receiver".into()); + self.transfer_tokens_status = TransferTokensStatus::Error; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Invalid receiver", + MessageType::Error, + ); return AppAction::None; } @@ -250,9 +261,10 @@ impl TransferTokensScreen { } impl ScreenLike for TransferTokensScreen { - 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. if let MessageType::Error = message_type { - self.transfer_tokens_status = TransferTokensStatus::ErrorMessage(message.to_string()); + self.transfer_tokens_status = TransferTokensStatus::Error; } } @@ -372,7 +384,7 @@ impl ScreenLike for TransferTokensScreen { } 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); @@ -509,12 +521,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!( @@ -570,11 +588,8 @@ impl ScreenLike for TransferTokensScreen { display_time )); } - 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 93d81fdcb..4f9a05beb 100644 --- a/src/ui/tokens/unfreeze_tokens_screen.rs +++ b/src/ui/tokens/unfreeze_tokens_screen.rs @@ -6,6 +6,7 @@ use crate::context::AppContext; 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::component_trait::Component; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::identity_selector::IdentitySelector; @@ -45,7 +46,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; pub enum UnfreezeTokensStatus { NotStarted, WaitingForResult(u64), - ErrorMessage(String), + Error, Complete, } @@ -69,7 +70,6 @@ pub struct UnfreezeTokensScreen { pub unfreeze_identity_id: String, status: UnfreezeTokensStatus, - error_message: Option, // Basic references pub app_context: Arc, @@ -102,7 +102,9 @@ impl UnfreezeTokensScreen { ) .cloned(); - let mut error_message = None; + let set_error_banner = |msg: &str| { + MessageBanner::set_global(app_context.egui_ctx(), msg, MessageType::Error); + }; let group = match identity_token_info .token_config @@ -110,32 +112,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("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 } @@ -147,7 +147,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 +162,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 +185,16 @@ impl UnfreezeTokensScreen { }; // 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 error_message, + &mut wallet_error, ); + if let Some(e) = wallet_error { + set_error_banner(&e); + } Self { identity: identity_token_info.identity.clone(), @@ -203,7 +207,6 @@ 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, @@ -261,8 +264,12 @@ impl UnfreezeTokensScreen { ], ); if parsed.is_err() { - self.error_message = Some("Please enter a valid identity ID.".into()); - self.status = UnfreezeTokensStatus::ErrorMessage("Invalid identity ID".into()); + self.status = UnfreezeTokensStatus::Error; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Please enter a valid identity ID.", + MessageType::Error, + ); return AppAction::None; } let unfreeze_id = parsed.unwrap(); @@ -324,10 +331,10 @@ impl UnfreezeTokensScreen { } impl ScreenLike for UnfreezeTokensScreen { - 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. if let MessageType::Error = message_type { - self.status = UnfreezeTokensStatus::ErrorMessage(message.to_string()); - self.error_message = Some(message.to_string()); + self.status = UnfreezeTokensStatus::Error; } } @@ -445,7 +452,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); @@ -607,25 +614,8 @@ impl ScreenLike for UnfreezeTokensScreen { let elapsed = now - start_time; ui.label(format!("Unfreezing... elapsed: {}s", elapsed)); } - 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 c839d5eaa..a31725a97 100644 --- a/src/ui/tokens/update_token_config.rs +++ b/src/ui/tokens/update_token_config.rs @@ -6,6 +6,7 @@ use crate::context::AppContext; 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::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; @@ -36,7 +37,7 @@ 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}; @@ -44,11 +45,11 @@ use std::sync::{Arc, RwLock}; pub enum UpdateTokenConfigStatus { NotUpdating, Updating(DateTime), + 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, @@ -68,7 +69,6 @@ pub struct UpdateTokenConfigScreen { selected_wallet: Option>>, wallet_unlock_popup: WalletUnlockPopup, - error_message: Option, // unused // Fee result from completed operation completed_fee_result: Option, } @@ -86,7 +86,7 @@ impl UpdateTokenConfigScreen { ) .cloned(); - let mut error_message = None; + let mut wallet_error = None; // Initialize with no group - will be set when user selects a change item let group = None; @@ -99,12 +99,14 @@ impl UpdateTokenConfigScreen { &identity_token_info.identity, None, possible_key.as_ref(), - &mut error_message, + &mut wallet_error, ); + if let Some(e) = wallet_error { + MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error); + } 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,7 +121,6 @@ impl UpdateTokenConfigScreen { selected_wallet, wallet_unlock_popup: WalletUnlockPopup::new(), - error_message, identity: identity_token_info.identity, group, @@ -201,9 +202,7 @@ impl UpdateTokenConfigScreen { self.group = group; if let Some(error) = error_message { - self.error_message = Some(error); - } else { - self.error_message = None; + MessageBanner::set_global(self.app_context.egui_ctx(), &error, MessageType::Error); } // Update is_unilateral_group_member based on new group @@ -917,38 +916,19 @@ 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 message_type == MessageType::Error { + 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.completed_fee_result = Some(fee_result); + self.update_status = UpdateTokenConfigStatus::Complete; } } @@ -993,11 +973,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 +1031,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,35 +1074,7 @@ 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); - } - }; - } + // Message display is handled by the global MessageBanner if self.update_status != UpdateTokenConfigStatus::NotUpdating { ui.add_space(10.0); 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..d244ad3a1 100644 --- a/src/ui/tools/address_balance_screen.rs +++ b/src/ui/tools/address_balance_screen.rs @@ -3,12 +3,12 @@ use crate::backend_task::platform_info::{PlatformInfoTaskRequestType, PlatformIn use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::context::AppContext; use crate::ui::components::left_panel::add_left_panel; +use crate::ui::components::message_banner::MessageBanner; 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) { + fn display_message(&mut self, _message: &str, message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. if message_type == MessageType::Error { - self.error_message = Some(message.to_string()); + self.is_loading = false; } } diff --git a/src/ui/tools/contract_visualizer_screen.rs b/src/ui/tools/contract_visualizer_screen.rs index 816b5242c..26eacd2fb 100644 --- a/src/ui/tools/contract_visualizer_screen.rs +++ b/src/ui/tools/contract_visualizer_screen.rs @@ -172,10 +172,9 @@ 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) { + // Banner display is handled globally by AppState. + // 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..69ecd7ed6 100644 --- a/src/ui/tools/document_visualizer_screen.rs +++ b/src/ui/tools/document_visualizer_screen.rs @@ -194,10 +194,9 @@ 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) { + // Banner display is handled globally by AppState. + // 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..c62007e57 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,9 @@ 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 + 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 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()), - } self.is_generating = false; self.is_verifying = false; } @@ -1034,7 +1024,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 +1047,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..16a2dfb2f 100644 --- a/src/ui/tools/masternode_list_diff_screen.rs +++ b/src/ui/tools/masternode_list_diff_screen.rs @@ -1535,7 +1535,8 @@ impl MasternodeListDiffScreen { .clicked() { self.clear(); - self.display_message("Cleared all data", MessageType::Success); + self.ui_state.message = + Some(("Cleared all data".to_string(), MessageType::Success)); } if ui .button("Clear keep base") @@ -1545,10 +1546,10 @@ impl MasternodeListDiffScreen { .clicked() { self.clear_keep_base(); - self.display_message( - "Cleared data and kept base diff", + self.ui_state.message = Some(( + "Cleared data and kept base diff".to_string(), MessageType::Success, - ); + )); } }); // Add bottom padding so the horizontal scrollbar doesn't overlap buttons @@ -4181,17 +4182,14 @@ impl MasternodeListDiffScreen { impl ScreenLike for MasternodeListDiffScreen { 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.task.pending = None; + // Preserve local error state so the screen's inline error rendering works 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. - } + MessageType::Success | MessageType::Info => {} } } @@ -4244,10 +4242,10 @@ impl ScreenLike for MasternodeListDiffScreen { if matches!(self.task.pending, Some(PendingTask::DmlDiffNoRotation)) { if let Some(task) = self.build_validation_diffs_task() { self.task.queued_task = Some(task); - self.display_message( - "Fetched DMLs (no rotation); fetching validation diffs…", + self.ui_state.message = Some(( + "Fetched DMLs (no rotation); fetching validation diffs…".to_string(), MessageType::Info, - ); + )); } else if !self.data.masternode_list_engine.masternode_lists.is_empty() { // Fallback: attempt verification directly if let Err(e) = self @@ -4261,14 +4259,21 @@ impl ScreenLike for MasternodeListDiffScreen { self.ui_state.error = Some(e.to_string()); } self.task.pending = None; - self.display_message("Fetched DMLs (no rotation)", MessageType::Success); + self.ui_state.message = Some(( + "Fetched DMLs (no rotation)".to_string(), + MessageType::Success, + )); } else { self.task.pending = None; - self.display_message("Fetched DMLs (no rotation)", MessageType::Success); + self.ui_state.message = Some(( + "Fetched DMLs (no rotation)".to_string(), + MessageType::Success, + )); } } else { self.task.pending = None; - self.display_message("Fetched DML diff", MessageType::Success); + self.ui_state.message = + Some(("Fetched DML diff".to_string(), MessageType::Success)); } self.selection.selected_dml_diff_key = None; self.selection.selected_quorum_in_diff_index = None; @@ -4326,13 +4331,14 @@ impl ScreenLike for MasternodeListDiffScreen { // Queue extra diffs required for verification (previous behavior) if let Some(task) = self.build_validation_diffs_task() { self.task.queued_task = Some(task); - self.display_message( - "Fetched QR info + DMLs; fetching validation diffs…", + self.ui_state.message = Some(( + "Fetched QR info + DMLs; fetching validation diffs…".to_string(), MessageType::Info, - ); + )); } else { self.task.pending = None; - self.display_message("Fetched QR info + DMLs", MessageType::Success); + self.ui_state.message = + Some(("Fetched QR info + DMLs".to_string(), MessageType::Success)); } } BackendTaskSuccessResult::MnListFetchedDiffs { items } => { @@ -4380,10 +4386,10 @@ impl ScreenLike for MasternodeListDiffScreen { self.ui_state.error = Some(e.to_string()); } self.task.pending = None; - self.display_message( - "Fetched validation diffs and verified non-rotating quorums", + self.ui_state.message = Some(( + "Fetched validation diffs and verified non-rotating quorums".to_string(), MessageType::Success, - ); + )); } BackendTaskSuccessResult::MnListChainLockSigs { entries } => { for ((h, bh), sig) in entries { @@ -4397,7 +4403,10 @@ impl ScreenLike for MasternodeListDiffScreen { } } self.task.pending = None; - self.display_message("Fetched chain lock signatures", MessageType::Success); + self.ui_state.message = Some(( + "Fetched chain lock signatures".to_string(), + MessageType::Success, + )); } _ => {} } diff --git a/src/ui/tools/platform_info_screen.rs b/src/ui/tools/platform_info_screen.rs index d85be3359..1cb4b2544 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) { + fn display_message(&mut self, _message: &str, message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. if message_type == MessageType::Error { - self.error_message = Some(message.to_string()); - // Clear loading states for all tasks 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 7cc157ee2..d43440afc 100644 --- a/src/ui/tools/transition_visualizer_screen.rs +++ b/src/ui/tools/transition_visualizer_screen.rs @@ -403,10 +403,10 @@ 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(_) @@ -415,12 +415,9 @@ impl ScreenLike for TransitionVisualizerScreen { } } MessageType::Error | MessageType::Warning => { - self.broadcast_status = - TransitionBroadcastStatus::Error(message.to_string(), Instant::now()); - } - MessageType::Info => { - // Could do nothing or handle info + 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..7847d17ef 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..f554b081c 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) { diff --git a/src/ui/wallets/send_screen.rs b/src/ui/wallets/send_screen.rs index 4e0c9f859..e138592be 100644 --- a/src/ui/wallets/send_screen.rs +++ b/src/ui/wallets/send_screen.rs @@ -6,6 +6,7 @@ use crate::context::AppContext; use crate::model::amount::{Amount, DASH_DECIMAL_PLACES}; use crate::model::fee_estimation::format_credits_as_dash; use crate::model::wallet::{Wallet, WalletSeedHash}; +use crate::ui::components::MessageBanner; use crate::ui::components::amount_input::AmountInput; use crate::ui::components::component_trait::{Component, ComponentResponse}; use crate::ui::components::left_panel::add_left_panel; @@ -287,8 +288,8 @@ pub enum SendStatus { WaitingForResult(u64), /// 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 @@ -392,7 +393,6 @@ pub struct WalletSendScreen { // Wallet unlock wallet_unlock_popup: WalletUnlockPopup, - error_message: Option, } impl WalletSendScreen { @@ -418,7 +418,6 @@ impl WalletSendScreen { subtract_fee: false, send_status: SendStatus::NotStarted, wallet_unlock_popup: WalletUnlockPopup::new(), - error_message: None, } } @@ -1059,7 +1058,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); @@ -1139,25 +1138,11 @@ impl WalletSendScreen { }); 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); @@ -1714,7 +1699,8 @@ impl WalletSendScreen { action = send_action; } Err(e) => { - self.display_message(&e, MessageType::Error); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + self.send_status = SendStatus::Error; } } } @@ -2290,7 +2276,8 @@ impl WalletSendScreen { action = send_action; } Err(e) => { - self.display_message(&e, MessageType::Error); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + self.send_status = SendStatus::Error; } } } @@ -2671,9 +2658,10 @@ 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_status = SendStatus::Error; } MessageType::Success => { self.send_status = SendStatus::Complete(message.to_string()); diff --git a/src/ui/wallets/single_key_send_screen.rs b/src/ui/wallets/single_key_send_screen.rs index 9528d9020..c053db104 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; @@ -983,10 +947,7 @@ impl ScreenLike for SingleKeyWalletSendScreen { 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/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index 712cd42d4..98fe03a52 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -10,6 +10,7 @@ use crate::context::AppContext; use crate::model::amount::Amount; use crate::model::wallet::{Wallet, WalletSeedHash, WalletTransaction}; use crate::spv::CoreBackendMode; +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; @@ -82,7 +83,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, @@ -92,7 +92,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, @@ -306,14 +303,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, + ); } } @@ -531,7 +536,8 @@ impl WalletsBalancesScreen { .db .remove_single_key_wallet(&key_hash, self.app_context.network) { - self.display_message( + MessageBanner::set_global( + ui.ctx(), &format!("Failed to remove: {}", e), MessageType::Error, ); @@ -543,7 +549,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, + ); } } @@ -698,13 +708,15 @@ impl WalletsBalancesScreen { self.wallet_unlock_popup.close(); self.refreshing = false; - self.display_message( + MessageBanner::set_global( + self.app_context.egui_ctx(), &format!("Removed wallet \"{}\" successfully", alias), MessageType::Success, ); } Err(err) => { - self.display_message( + MessageBanner::set_global( + self.app_context.egui_ctx(), &format!("Failed to remove wallet: {}", err), MessageType::Error, ); @@ -776,18 +788,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() } @@ -880,7 +880,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, + ); } } @@ -1205,7 +1209,8 @@ impl WalletsBalancesScreen { let mut wallet = match wallet_arc.write() { Ok(guard) => guard, Err(err) => { - self.display_message( + MessageBanner::set_global( + self.app_context.egui_ctx(), &format!("Failed to lock wallet: {}", err), MessageType::Error, ); @@ -1223,7 +1228,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, + ); } } @@ -1303,8 +1312,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() { @@ -1367,38 +1374,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]) @@ -1531,7 +1507,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); } } } @@ -1557,7 +1533,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, ); @@ -1650,45 +1627,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; @@ -1752,7 +1707,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), )); @@ -1766,6 +1725,7 @@ impl ScreenLike for WalletsBalancesScreen { } fn display_message(&mut self, message: &str, message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. if let MessageType::Error = message_type { self.refreshing = false; @@ -1774,10 +1734,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( @@ -1788,13 +1746,15 @@ impl ScreenLike for WalletsBalancesScreen { crate::ui::BackendTaskSuccessResult::RefreshedWallet { warning } => { self.refreshing = false; if let Some(warn_msg) = warning { - self.set_message( - format!("Wallet refreshed with warning: {}", warn_msg), + 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, ); } @@ -1812,7 +1772,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, @@ -1835,7 +1795,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 @@ -1861,16 +1821,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, ); @@ -1892,14 +1861,15 @@ impl ScreenLike for WalletsBalancesScreen { wallet.set_platform_address_info(addr, balance, nonce); } } - 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); } _ => {} } From 812e593ed71164d1c4ad893005ff677fe56cecdb Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 18 Feb 2026 21:18:21 +0100 Subject: [PATCH 11/46] chore: use message banner to show progress --- src/app.rs | 1 - .../add_contracts_screen.rs | 60 ++++----- .../contracts_documents_screen.rs | 95 ++++++++------ .../group_actions_screen.rs | 53 ++++---- src/ui/dpns/dpns_contested_names_screen.rs | 106 ++++++++++------ .../add_existing_identity_screen.rs | 117 ++++++++---------- src/ui/identities/keys/add_key_screen.rs | 68 +++------- .../identities/register_dpns_name_screen.rs | 68 +++------- src/ui/identities/transfer_screen.rs | 88 +++++-------- src/ui/identities/withdraw_screen.rs | 78 ++++-------- src/ui/mod.rs | 4 +- src/ui/tokens/burn_tokens_screen.rs | 36 +++--- src/ui/tokens/claim_tokens_screen.rs | 37 +++--- src/ui/tokens/destroy_frozen_funds_screen.rs | 39 +++--- src/ui/tokens/direct_token_purchase_screen.rs | 36 +++--- src/ui/tokens/freeze_tokens_screen.rs | 36 +++--- src/ui/tokens/mint_tokens_screen.rs | 43 ++++--- src/ui/tokens/pause_tokens_screen.rs | 36 +++--- src/ui/tokens/resume_tokens_screen.rs | 36 +++--- src/ui/tokens/set_token_price_screen.rs | 38 +++--- src/ui/tokens/tokens_screen/keyword_search.rs | 22 ++-- src/ui/tokens/tokens_screen/mod.rs | 72 +++++++---- src/ui/tokens/tokens_screen/my_tokens.rs | 32 ++++- src/ui/tokens/tokens_screen/token_creator.rs | 27 ++-- src/ui/tokens/transfer_tokens_screen.rs | 62 ++++------ src/ui/tokens/unfreeze_tokens_screen.rs | 36 +++--- src/ui/tools/transition_visualizer_screen.rs | 66 ++++------ src/ui/wallets/send_screen.rs | 86 ++++++------- 28 files changed, 701 insertions(+), 777 deletions(-) diff --git a/src/app.rs b/src/app.rs index 75ed7a9c0..f03f332b7 100644 --- a/src/app.rs +++ b/src/app.rs @@ -858,7 +858,6 @@ impl App for AppState { self.visible_screen_mut().refresh(); } _ => { - MessageBanner::set_global(ctx, "Success", MessageType::Success); self.visible_screen_mut() .display_task_result(unboxed_message); } diff --git a/src/ui/contracts_documents/add_contracts_screen.rs b/src/ui/contracts_documents/add_contracts_screen.rs index d1ea3c1f2..3414c844f 100644 --- a/src/ui/contracts_documents/add_contracts_screen.rs +++ b/src/ui/contracts_documents/add_contracts_screen.rs @@ -3,7 +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::MessageBanner; +use crate::ui::components::message_banner::{BannerHandle, MessageBanner}; use crate::ui::components::styled::island_central_panel; use crate::ui::components::top_panel::add_top_panel; use crate::ui::theme::DashColors; @@ -11,17 +11,15 @@ 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, 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), Error, } @@ -33,6 +31,7 @@ pub struct AddContractsScreen { maybe_found_contracts: Vec, alias_inputs: Option>, last_alias_result: Option<(usize, Result)>, + add_banner: Option, } impl AddContractsScreen { @@ -44,6 +43,7 @@ impl AddContractsScreen { maybe_found_contracts: vec![], alias_inputs: None, last_alias_result: None, + add_banner: None, } } @@ -80,12 +80,17 @@ 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) - .expect("Time went backwards") - .as_secs(), + if let Some(h) = self.add_banner.take() { + h.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), ))) @@ -275,6 +280,9 @@ impl ScreenLike for AddContractsScreen { // Banner display is handled globally by AppState; this is only for side-effects. match message_type { MessageType::Error | MessageType::Warning => { + if let Some(h) = self.add_banner.take() { + h.clear(); + } self.add_contracts_status = AddContractsStatus::Error; } MessageType::Success | MessageType::Info => {} @@ -284,6 +292,9 @@ 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) => { + if let Some(h) = self.add_banner.take() { + h.clear(); + } let maybe_contracts: Vec<_> = self .contract_ids_input .iter() @@ -342,35 +353,8 @@ impl ScreenLike for AddContractsScreen { return self.add_contracts_clicked(); } } - AddContractsStatus::WaitingForResult(start_time) => { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .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 3cb630b49..44b1a7e4d 100644 --- a/src/ui/contracts_documents/contracts_documents_screen.rs +++ b/src/ui/contracts_documents/contracts_documents_screen.rs @@ -10,7 +10,7 @@ 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::MessageBanner; +use crate::ui::components::message_banner::{BannerHandle, MessageBanner}; use crate::ui::components::top_panel::add_top_panel; use crate::ui::theme::{DashColors, Shadow, Shape}; use crate::ui::{BackendTaskSuccessResult, MessageType, RootScreenType, ScreenLike, ScreenType}; @@ -20,13 +20,11 @@ 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. @@ -69,12 +67,13 @@ 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, Error, } @@ -132,6 +131,7 @@ impl DocumentQueryScreen { has_next_page: false, previous_cursors: Vec::new(), contract_chooser_state: ContractChooserState::default(), + query_banner: None, } } @@ -190,12 +190,18 @@ 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) - .expect("Time went backwards") - .as_secs(); - self.document_query_status = DocumentQueryStatus::WaitingForResult(now); + // Set the status to waiting + if let Some(h) = self.query_banner.take() { + h.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 @@ -321,19 +327,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) - .expect("Time went backwards") - .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 => { @@ -363,12 +358,17 @@ 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) - .expect("Time went backwards") - .as_secs(), + if let Some(h) = self.query_banner.take() { + h.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); @@ -376,12 +376,17 @@ impl DocumentQueryScreen { DocumentTask::FetchDocumentsPage(parsed_query), ))); } else { - self.document_query_status = DocumentQueryStatus::WaitingForResult( - SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .as_secs(), + if let Some(h) = self.query_banner.take() { + h.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 @@ -397,12 +402,17 @@ 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) - .expect("Time went backwards") - .as_secs(), + if let Some(h) = self.query_banner.take() { + h.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 @@ -542,6 +552,9 @@ impl ScreenLike for DocumentQueryScreen { if message.contains("Error fetching documents") && matches!(message_type, MessageType::Error | MessageType::Warning) { + if let Some(h) = self.query_banner.take() { + h.clear(); + } self.document_query_status = DocumentQueryStatus::Error; } } @@ -549,6 +562,9 @@ impl ScreenLike for DocumentQueryScreen { fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { match backend_task_success_result { BackendTaskSuccessResult::Documents(documents) => { + if let Some(h) = self.query_banner.take() { + h.clear(); + } self.matching_documents = documents .iter() .filter_map(|(_, doc)| doc.clone()) @@ -556,6 +572,9 @@ impl ScreenLike for DocumentQueryScreen { self.document_query_status = DocumentQueryStatus::Complete; } BackendTaskSuccessResult::PageDocuments(page_docs, next_cursor) => { + if let Some(h) = self.query_banner.take() { + h.clear(); + } self.matching_documents = page_docs .iter() .filter_map(|(_, doc)| doc.clone()) diff --git a/src/ui/contracts_documents/group_actions_screen.rs b/src/ui/contracts_documents/group_actions_screen.rs index 7cdb51d9f..67bcb235d 100644 --- a/src/ui/contracts_documents/group_actions_screen.rs +++ b/src/ui/contracts_documents/group_actions_screen.rs @@ -17,7 +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::MessageBanner; +use crate::ui::components::message_banner::{BannerHandle, MessageBanner}; 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; @@ -45,7 +45,6 @@ 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; @@ -55,12 +54,11 @@ 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), Error, } @@ -85,6 +83,7 @@ pub struct GroupActionsScreen { // Backend task status fetch_group_actions_status: FetchGroupActionsStatus, + fetch_banner: Option, // App Context pub app_context: Arc, @@ -150,6 +149,7 @@ impl GroupActionsScreen { // Backend task status fetch_group_actions_status: FetchGroupActionsStatus::NotStarted, + fetch_banner: None, // App Context app_context: app_context.clone(), @@ -462,6 +462,9 @@ impl ScreenLike for GroupActionsScreen { // Banner display is handled globally by AppState; this is only for side-effects. match message_type { MessageType::Error | MessageType::Warning => { + if let Some(h) = self.fetch_banner.take() { + h.clear(); + } self.fetch_group_actions_status = FetchGroupActionsStatus::Error; } MessageType::Success | MessageType::Info => {} @@ -472,6 +475,9 @@ impl ScreenLike for GroupActionsScreen { if let BackendTaskSuccessResult::ActiveGroupActions(actions_map) = backend_task_success_result { + if let Some(h) = self.fetch_banner.take() { + h.clear(); + } self.fetch_group_actions_status = FetchGroupActionsStatus::Complete(actions_map.clone()); } @@ -563,12 +569,17 @@ 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) - .expect("Time went backwards") - .as_secs(), + if let Some(h) = self.fetch_banner.take() { + h.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; } } @@ -578,28 +589,8 @@ impl ScreenLike for GroupActionsScreen { // Error message is displayed globally via MessageBanner } - FetchGroupActionsStatus::WaitingForResult(start_time) => { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .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/dpns/dpns_contested_names_screen.rs b/src/ui/dpns/dpns_contested_names_screen.rs index 7c0385a55..26d68d743 100644 --- a/src/ui/dpns/dpns_contested_names_screen.rs +++ b/src/ui/dpns/dpns_contested_names_screen.rs @@ -18,12 +18,12 @@ use crate::backend_task::identity::IdentityTask; use crate::context::AppContext; use crate::model::contested_name::{ContestState, ContestedName}; use crate::model::qualified_identity::{DPNSNameInfo, QualifiedIdentity}; -use crate::ui::components::MessageBanner; use crate::ui::components::dpns_subscreen_chooser_panel::add_dpns_subscreen_chooser_panel; 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}; use crate::ui::identities::register_dpns_name_screen::RegisterDpnsNameSource; use crate::ui::theme::DashColors; use crate::ui::{BackendTaskSuccessResult, MessageType, RootScreenType, ScreenLike, ScreenType}; @@ -75,7 +75,7 @@ pub enum ScheduledVoteCastingStatus { #[derive(PartialEq)] pub enum VoteHandlingStatus { NotStarted, - CastingVotes(u64), + CastingVotes, SchedulingVotes, Completed, Failed(String), @@ -83,7 +83,7 @@ pub enum VoteHandlingStatus { #[derive(PartialEq)] pub enum RefreshingStatus { - Refreshing(u64), + Refreshing, NotRefreshing, } @@ -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, } @@ -197,12 +199,14 @@ 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, } } @@ -290,11 +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.refreshing_status = RefreshingStatus::Refreshing; match self.dpns_subscreen { DPNSSubscreen::Active | DPNSSubscreen::Past => { app_action = AppAction::BackendTask(BackendTask::ContestedResourceTask( @@ -1538,6 +1541,15 @@ impl DPNSScreen { .corner_radius(3.0); if ui.add(button).clicked() { action = self.bulk_apply_votes(); + if self.bulk_vote_handling_status == VoteHandlingStatus::CastingVotes { + if let Some(h) = self.vote_banner.take() { + h.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); @@ -1546,20 +1558,17 @@ impl DPNSScreen { self.show_bulk_schedule_popup = false; self.bulk_schedule_message = None; self.bulk_vote_handling_status = VoteHandlingStatus::NotStarted; + if let Some(h) = self.vote_banner.take() { + h.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; @@ -1636,8 +1645,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![ @@ -1822,8 +1830,16 @@ impl ScreenLike for DPNSScreen { self.refresh(); } - 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. + if matches!(message_type, MessageType::Error | MessageType::Warning) { + if let Some(h) = self.refresh_banner.take() { + h.clear(); + } + if let Some(h) = self.vote_banner.take() { + h.clear(); + } + } if message.contains("Error casting scheduled vote") { self.scheduled_vote_cast_in_progress = false; if let Ok(mut guard) = self.scheduled_votes.lock() { @@ -1875,11 +1891,17 @@ impl ScreenLike for DPNSScreen { )); } + if let Some(h) = self.vote_banner.take() { + h.clear(); + } self.bulk_vote_handling_status = VoteHandlingStatus::Completed; } // If scheduling succeeded BackendTaskSuccessResult::ScheduledVotes => { if self.bulk_vote_handling_status == VoteHandlingStatus::SchedulingVotes { + if let Some(h) = self.vote_banner.take() { + h.clear(); + } self.bulk_vote_handling_status = VoteHandlingStatus::Completed; } self.bulk_schedule_message = @@ -1897,6 +1919,9 @@ impl ScreenLike for DPNSScreen { } BackendTaskSuccessResult::RefreshedDpnsContests | BackendTaskSuccessResult::RefreshedOwnedDpnsNames => { + if let Some(h) = self.refresh_banner.take() { + h.clear(); + } self.refreshing_status = RefreshingStatus::NotRefreshing; } _ => {} @@ -2073,22 +2098,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 - } + // Refreshing indicator is shown via the global banner + // (no inline elapsed rendering needed) inner_action }); @@ -2098,17 +2109,38 @@ impl ScreenLike for DPNSScreen { AppAction::BackendTask(BackendTask::ContestedResourceTask( ContestedResourceTask::QueryDPNSContests, )) => { - self.refreshing_status = - RefreshingStatus::Refreshing(Utc::now().timestamp() as u64); + if let Some(h) = self.refresh_banner.take() { + h.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); + if let Some(h) = self.refresh_banner.take() { + h.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(_) => { + if let Some(h) = self.refresh_banner.take() { + h.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 5828b62b6..1d5406fdd 100644 --- a/src/ui/identities/add_existing_identity_screen.rs +++ b/src/ui/identities/add_existing_identity_screen.rs @@ -4,7 +4,6 @@ use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::context::AppContext; use crate::model::qualified_identity::IdentityType; use crate::model::wallet::Wallet; -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; @@ -12,11 +11,11 @@ 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}; 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; @@ -25,7 +24,6 @@ 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 { @@ -77,7 +75,7 @@ enum WalletIdentitySearchMode { #[derive(PartialEq)] pub enum AddIdentityStatus { NotStarted, - WaitingForResult(TimestampMillis), + WaitingForResult, Error, Complete, } @@ -105,6 +103,7 @@ pub struct AddExistingIdentityScreen { dpns_name_input: String, /// Whether to show advanced options show_advanced_options: bool, + refresh_banner: Option, } impl AddExistingIdentityScreen { @@ -137,6 +136,7 @@ impl AddExistingIdentityScreen { success_message: None, dpns_name_input: String::new(), show_advanced_options: false, + refresh_banner: None, } } @@ -451,11 +451,14 @@ impl AddExistingIdentityScreen { .corner_radius(3.0); if ui.add_enabled(is_valid_id, button).clicked() { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .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(); } @@ -652,13 +655,16 @@ impl AddExistingIdentityScreen { .corner_radius(3.0); if ui.add(button).clicked() { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .as_secs(); - self.add_identity_status = AddIdentityStatus::WaitingForResult(now); + self.add_identity_status = AddIdentityStatus::WaitingForResult; self.backend_message = None; 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::() { @@ -813,13 +819,16 @@ impl AddExistingIdentityScreen { .corner_radius(3.0); if ui.add_enabled(is_valid, button).clicked() { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .as_secs(); - self.add_identity_status = AddIdentityStatus::WaitingForResult(now); + self.add_identity_status = AddIdentityStatus::WaitingForResult; self.backend_message = None; 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 { @@ -956,6 +965,9 @@ impl ScreenLike for AddExistingIdentityScreen { // Side-effects only: update status and progress tracking. match message_type { MessageType::Error => { + if let Some(handle) = self.refresh_banner.take() { + handle.clear(); + } self.add_identity_status = AddIdentityStatus::Error; } MessageType::Success => { @@ -963,12 +975,18 @@ impl ScreenLike for AddExistingIdentityScreen { if message.starts_with("Successfully loaded") || message.starts_with("Finished loading") { + if let Some(handle) = self.refresh_banner.take() { + handle.clear(); + } self.success_message = Some(message.to_string()); self.add_identity_status = AddIdentityStatus::Complete; self.backend_message = None; } else { - // This is a progress update + // This is a progress update - update the banner text self.backend_message = Some(message.to_string()); + if let Some(ref handle) = self.refresh_banner { + handle.set_message(message); + } } } _ => {} @@ -978,6 +996,9 @@ impl ScreenLike for AddExistingIdentityScreen { fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { match backend_task_success_result { BackendTaskSuccessResult::LoadedIdentity(_) => { + if let Some(handle) = self.refresh_banner.take() { + handle.clear(); + } self.success_message = Some("Successfully loaded identity.".to_string()); self.add_identity_status = AddIdentityStatus::Complete; self.backend_message = None; @@ -985,12 +1006,18 @@ impl ScreenLike for AddExistingIdentityScreen { 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") { + if let Some(handle) = self.refresh_banner.take() { + handle.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 + self.backend_message = Some(msg.clone()); + if let Some(ref handle) = self.refresh_banner { + handle.set_message(&msg); + } } } _ => {} @@ -1087,51 +1114,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) - .expect("Time went backwards") - .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::Error => { - // Error display is handled by the global MessageBanner - } - AddIdentityStatus::Complete => { - // handled above - } - } + // Status display is handled by the global MessageBanner }); inner_action diff --git a/src/ui/identities/keys/add_key_screen.rs b/src/ui/identities/keys/add_key_screen.rs index 56fdcdbca..381b75e00 100644 --- a/src/ui/identities/keys/add_key_screen.rs +++ b/src/ui/identities/keys/add_key_screen.rs @@ -6,13 +6,13 @@ use crate::model::fee_estimation::format_credits_as_dash; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::qualified_identity::qualified_identity_public_key::QualifiedIdentityPublicKey; 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_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; +use crate::ui::components::{BannerHandle, MessageBanner}; use crate::ui::identities::get_selected_wallet; use crate::ui::theme::DashColors; use crate::ui::{MessageType, ScreenLike}; @@ -25,17 +25,15 @@ 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), + WaitingForResult, Error, Complete, } @@ -55,6 +53,7 @@ pub struct AddKeyScreen { enable_contract_bounds: bool, // Fee result from completed operation completed_fee_result: Option, + refresh_banner: Option, } impl AddKeyScreen { @@ -86,6 +85,7 @@ impl AddKeyScreen { document_type_input: String::new(), enable_contract_bounds: false, completed_fee_result: None, + refresh_banner: None, } } @@ -127,6 +127,7 @@ impl AddKeyScreen { document_type_input: String::new(), enable_contract_bounds: true, completed_fee_result: None, + refresh_banner: None, } } @@ -168,6 +169,7 @@ impl AddKeyScreen { document_type_input: String::new(), enable_contract_bounds: true, completed_fee_result: None, + refresh_banner: None, } } @@ -353,6 +355,9 @@ impl ScreenLike for AddKeyScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { // Error/success display is handled by the global MessageBanner. if let MessageType::Error = message_type { + if let Some(handle) = self.refresh_banner.take() { + handle.clear(); + } self.add_key_status = AddKeyStatus::Error; } } @@ -360,6 +365,9 @@ impl ScreenLike for AddKeyScreen { fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { match backend_task_success_result { BackendTaskSuccessResult::AddedKeyToIdentity(fee_result) => { + if let Some(handle) = self.refresh_banner.take() { + handle.clear(); + } self.completed_fee_result = Some(fee_result); self.add_key_status = AddKeyStatus::Complete; } @@ -668,54 +676,14 @@ 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) - .expect("Time went backwards") - .as_secs(); - self.add_key_status = AddKeyStatus::WaitingForResult(now); + 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 |= 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) - .expect("Time went backwards") - .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::Error => { - // Error display is handled by the global MessageBanner - } - AddKeyStatus::Complete => { - // handled above - } - } + // Status display is handled by the global MessageBanner inner_action }); diff --git a/src/ui/identities/register_dpns_name_screen.rs b/src/ui/identities/register_dpns_name_screen.rs index 09525777c..3dd21be1c 100644 --- a/src/ui/identities/register_dpns_name_screen.rs +++ b/src/ui/identities/register_dpns_name_screen.rs @@ -5,7 +5,6 @@ use crate::context::AppContext; 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::identity_selector::IdentitySelector; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; @@ -13,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}; 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; @@ -40,7 +39,7 @@ pub enum RegisterDpnsNameSource { #[derive(PartialEq)] pub enum RegisterDpnsNameStatus { NotStarted, - WaitingForResult(TimestampMillis), + WaitingForResult, Error, Complete, } @@ -61,6 +60,7 @@ pub struct RegisterDpnsNameScreen { completed_fee_result: Option, // Source of navigation to this screen pub source: RegisterDpnsNameSource, + refresh_banner: Option, } impl RegisterDpnsNameScreen { @@ -124,6 +124,7 @@ impl RegisterDpnsNameScreen { show_advanced_options: false, completed_fee_result: None, source, + refresh_banner: None, } } @@ -303,6 +304,9 @@ impl ScreenLike for RegisterDpnsNameScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. if let MessageType::Error = message_type { + if let Some(handle) = self.refresh_banner.take() { + handle.clear(); + } self.register_dpns_name_status = RegisterDpnsNameStatus::Error; } } @@ -311,6 +315,9 @@ impl ScreenLike for RegisterDpnsNameScreen { if let BackendTaskSuccessResult::RegisteredDpnsName(fee_result) = backend_task_success_result { + if let Some(handle) = self.refresh_banner.take() { + handle.clear(); + } self.completed_fee_result = Some(fee_result); self.register_dpns_name_status = RegisterDpnsNameStatus::Complete; } @@ -548,58 +555,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) - .expect("Time went backwards") - .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) - .expect("Time went backwards") - .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::Error => { - // Error display is handled by the global MessageBanner - } - 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 75a46f546..e1b4fc7cb 100644 --- a/src/ui/identities/transfer_screen.rs +++ b/src/ui/identities/transfer_screen.rs @@ -6,7 +6,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}; @@ -14,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}; use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; use crate::ui::{MessageType, Screen, ScreenLike}; use dash_sdk::dashcore_rpc::dashcore::Address; @@ -24,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; @@ -51,7 +49,7 @@ pub enum TransferDestinationType { #[derive(PartialEq)] pub enum TransferCreditsStatus { NotStarted, - WaitingForResult(TimestampMillis), + WaitingForResult, Error, Complete, } @@ -76,6 +74,7 @@ pub struct TransferScreen { show_advanced_options: bool, // Fee result from completed operation completed_fee_result: Option, + refresh_banner: Option, } impl TransferScreen { @@ -115,6 +114,7 @@ impl TransferScreen { platform_address_input: String::new(), show_advanced_options: false, completed_fee_result: None, + refresh_banner: None, } } @@ -147,7 +147,7 @@ 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::WaitingForResult | TransferCreditsStatus::Complete => false, TransferCreditsStatus::NotStarted | TransferCreditsStatus::Error => { amount_input.set_max_amount(Some(max_amount_credits)); true @@ -322,11 +322,14 @@ impl TransferScreen { } // Set waiting state - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .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(); @@ -378,11 +381,14 @@ impl TransferScreen { } // Set waiting state and create backend task - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .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(), @@ -497,6 +503,9 @@ impl ScreenLike for TransferScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. if let MessageType::Error = message_type { + if let Some(handle) = self.refresh_banner.take() { + handle.clear(); + } self.transfer_credits_status = TransferCreditsStatus::Error; } } @@ -505,6 +514,9 @@ impl ScreenLike for TransferScreen { if let BackendTaskSuccessResult::TransferredCredits(fee_result) = backend_task_success_result { + if let Some(handle) = self.refresh_banner.take() { + handle.clear(); + } self.completed_fee_result = Some(fee_result); self.transfer_credits_status = TransferCreditsStatus::Complete; } @@ -718,7 +730,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,49 +774,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) - .expect("Time went backwards") - .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::Error => { - // Error display is handled by the global MessageBanner - } - 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 499615348..ab40c3901 100644 --- a/src/ui/identities/withdraw_screen.rs +++ b/src/ui/identities/withdraw_screen.rs @@ -7,7 +7,6 @@ use crate::model::fee_estimation::format_credits_as_dash; use crate::model::qualified_identity::encrypted_key_storage::PrivateKeyData; use crate::model::qualified_identity::{IdentityType, PrivateKeyTarget, 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::left_panel::add_left_panel; @@ -16,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}; use crate::ui::components::{Component, ComponentResponse}; use crate::ui::helpers::{TransactionType, add_key_chooser}; use crate::ui::theme::DashColors; @@ -26,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; @@ -41,7 +39,7 @@ use super::keys::key_info_screen::KeyInfoScreen; #[derive(PartialEq)] pub enum WithdrawFromIdentityStatus { NotStarted, - WaitingForResult(TimestampMillis), + WaitingForResult, Error, Complete, } @@ -62,6 +60,7 @@ pub struct WithdrawalScreen { show_advanced_options: bool, // Fee result from completed operation completed_fee_result: Option, + refresh_banner: Option, } impl WithdrawalScreen { @@ -94,6 +93,7 @@ impl WithdrawalScreen { wallet_unlock_popup: WalletUnlockPopup::new(), show_advanced_options: false, completed_fee_result: None, + refresh_banner: None, } } @@ -120,8 +120,9 @@ 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::WaitingForResult | WithdrawFromIdentityStatus::Complete => { + false + } WithdrawFromIdentityStatus::NotStarted | WithdrawFromIdentityStatus::Error => { amount_input.set_max_amount(Some(max_amount_credits)); true @@ -280,12 +281,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) - .expect("Time went backwards") - .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 @@ -328,6 +331,9 @@ impl ScreenLike for WithdrawalScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. if let MessageType::Error = message_type { + if let Some(handle) = self.refresh_banner.take() { + handle.clear(); + } self.withdraw_from_identity_status = WithdrawFromIdentityStatus::Error; } } @@ -336,6 +342,9 @@ impl ScreenLike for WithdrawalScreen { if let BackendTaskSuccessResult::WithdrewFromIdentity(fee_result) = backend_task_success_result { + if let Some(handle) = self.refresh_banner.take() { + handle.clear(); + } self.completed_fee_result = Some(fee_result); self.withdraw_from_identity_status = WithdrawFromIdentityStatus::Complete; } @@ -609,50 +618,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) - .expect("Time went backwards") - .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!( - "Withdrawing... Time taken so far: {}", - display_time - )); - } - WithdrawFromIdentityStatus::Error => { - // Error display is handled by the global MessageBanner - } - WithdrawFromIdentityStatus::Complete => { - // Handled above - } - } + // Status display is handled by the global MessageBanner } inner_action diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 84b8ac96b..5c56c635b 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -841,9 +841,7 @@ pub trait ScreenLike { } fn ui(&mut self, ctx: &Context) -> AppAction; 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) - } + fn display_task_result(&mut self, _backend_task_success_result: BackendTaskSuccessResult) {} fn pop_on_success(&mut self) {} } diff --git a/src/ui/tokens/burn_tokens_screen.rs b/src/ui/tokens/burn_tokens_screen.rs index ac830e92a..ccff95524 100644 --- a/src/ui/tokens/burn_tokens_screen.rs +++ b/src/ui/tokens/burn_tokens_screen.rs @@ -6,7 +6,7 @@ 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::{Component, ComponentResponse}; +use crate::ui::components::{BannerHandle, Component, ComponentResponse}; use crate::ui::helpers::{TransactionType, add_key_chooser, render_group_action_text}; use crate::ui::theme::DashColors; use crate::ui::tokens::tokens_screen::IdentityTokenIdentifier; @@ -26,7 +26,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; @@ -49,7 +48,7 @@ use super::tokens_screen::IdentityTokenInfo; #[derive(PartialEq)] pub enum BurnTokensStatus { NotStarted, - WaitingForResult(u64), + WaitingForResult, Error, Complete, } @@ -81,6 +80,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 { @@ -219,6 +220,7 @@ impl BurnTokensScreen { selected_wallet, wallet_unlock_popup: WalletUnlockPopup::new(), completed_fee_result: None, + refresh_banner: None, } } @@ -269,11 +271,14 @@ impl BurnTokensScreen { match dialog.show(ui).inner.dialog_response { Some(ConfirmationStatus::Confirmed) => { self.confirmation_dialog = None; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .as_secs(); - self.status = BurnTokensStatus::WaitingForResult(now); + 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 = @@ -342,12 +347,18 @@ impl ScreenLike for BurnTokensScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. if let MessageType::Error = message_type { + if let Some(h) = self.refresh_banner.take() { + h.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 { + if let Some(h) = self.refresh_banner.take() { + h.clear(); + } self.completed_fee_result = Some(fee_result); self.status = BurnTokensStatus::Complete; } @@ -659,13 +670,8 @@ impl ScreenLike for BurnTokensScreen { BurnTokensStatus::NotStarted => { // no-op } - BurnTokensStatus::WaitingForResult(start_time) => { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .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::Error => { // Error display is handled by the global MessageBanner diff --git a/src/ui/tokens/claim_tokens_screen.rs b/src/ui/tokens/claim_tokens_screen.rs index c21500438..d1bca872a 100644 --- a/src/ui/tokens/claim_tokens_screen.rs +++ b/src/ui/tokens/claim_tokens_screen.rs @@ -1,15 +1,15 @@ use crate::backend_task::{BackendTaskSuccessResult, FeeResult}; use crate::model::fee_estimation::format_credits_as_dash; use crate::ui::components::Component; -use crate::ui::components::MessageBanner; 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::{BannerHandle, MessageBanner}; 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; @@ -44,7 +44,7 @@ use super::tokens_screen::IdentityTokenBasicInfo; #[derive(PartialEq)] pub enum ClaimTokensStatus { NotStarted, - WaitingForResult(u64), + WaitingForResult, Error, Complete, } @@ -65,6 +65,8 @@ pub struct ClaimTokensScreen { wallet_unlock_popup: WalletUnlockPopup, // Fee result from completed operation completed_fee_result: Option, + // Banner handle for elapsed time display + refresh_banner: Option, } impl ClaimTokensScreen { @@ -135,6 +137,7 @@ impl ClaimTokensScreen { selected_wallet, wallet_unlock_popup: WalletUnlockPopup::new(), completed_fee_result: None, + refresh_banner: None, } } @@ -204,11 +207,14 @@ impl ClaimTokensScreen { match dialog.show(ui).inner.dialog_response { Some(ConfirmationStatus::Confirmed) => { self.confirmation_dialog = None; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .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![ @@ -247,12 +253,18 @@ impl ScreenLike for ClaimTokensScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. if let MessageType::Error = message_type { + if let Some(h) = self.refresh_banner.take() { + h.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 { + if let Some(h) = self.refresh_banner.take() { + h.clear(); + } self.completed_fee_result = Some(fee_result); self.status = ClaimTokensStatus::Complete; } @@ -567,13 +579,8 @@ 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() - .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::Error => { // Error display is handled by the global MessageBanner diff --git a/src/ui/tokens/destroy_frozen_funds_screen.rs b/src/ui/tokens/destroy_frozen_funds_screen.rs index 2c85e3c8a..3484f40c4 100644 --- a/src/ui/tokens/destroy_frozen_funds_screen.rs +++ b/src/ui/tokens/destroy_frozen_funds_screen.rs @@ -6,7 +6,6 @@ use crate::context::AppContext; 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::component_trait::Component; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::identity_selector::IdentitySelector; @@ -17,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::{BannerHandle, MessageBanner}; 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; @@ -38,13 +38,12 @@ 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), + WaitingForResult, Error, Complete, } @@ -89,6 +88,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 { @@ -219,6 +220,7 @@ impl DestroyFrozenFundsScreen { selected_wallet, wallet_unlock_popup: WalletUnlockPopup::new(), completed_fee_result: None, + refresh_banner: None, } } @@ -281,11 +283,14 @@ impl DestroyFrozenFundsScreen { } let frozen_id = maybe_frozen_id.unwrap(); - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .as_secs(); - self.status = DestroyFrozenFundsStatus::WaitingForResult(now); + 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,6 +344,9 @@ impl ScreenLike for DestroyFrozenFundsScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. if let MessageType::Error = message_type { + if let Some(h) = self.refresh_banner.take() { + h.clear(); + } self.status = DestroyFrozenFundsStatus::Error; } } @@ -347,6 +355,9 @@ impl ScreenLike for DestroyFrozenFundsScreen { if let BackendTaskSuccessResult::DestroyedFrozenFunds(fee_result) = backend_task_success_result { + if let Some(h) = self.refresh_banner.take() { + h.clear(); + } self.completed_fee_result = Some(fee_result); self.status = DestroyFrozenFundsStatus::Complete; } @@ -619,16 +630,8 @@ impl ScreenLike for DestroyFrozenFundsScreen { DestroyFrozenFundsStatus::NotStarted => { // no-op } - DestroyFrozenFundsStatus::WaitingForResult(start_time) => { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .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::Error => { // Error display is handled by the global MessageBanner diff --git a/src/ui/tokens/direct_token_purchase_screen.rs b/src/ui/tokens/direct_token_purchase_screen.rs index 5db3ce109..e55dcc194 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; @@ -18,7 +17,6 @@ use crate::context::AppContext; use crate::model::amount::{Amount, DASH_DECIMAL_PLACES}; use crate::model::fee_estimation::format_credits_as_dash; 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::left_panel::add_left_panel; @@ -28,6 +26,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}; use crate::ui::components::{Component, ComponentResponse}; use crate::ui::helpers::{TransactionType, add_key_chooser}; use crate::ui::identities::get_selected_wallet; @@ -43,7 +42,7 @@ use dash_sdk::platform::IdentityPublicKey; #[derive(PartialEq)] pub enum PurchaseTokensStatus { NotStarted, - WaitingForResult(u64), // Use seconds or millis + WaitingForResult, Error, Complete, } @@ -72,6 +71,8 @@ pub struct PurchaseTokenScreen { wallet_unlock_popup: WalletUnlockPopup, // Fee result from completed operation completed_fee_result: Option, + // Banner handle for elapsed time display + refresh_banner: Option, } impl PurchaseTokenScreen { @@ -115,6 +116,7 @@ impl PurchaseTokenScreen { selected_wallet, wallet_unlock_popup: WalletUnlockPopup::new(), completed_fee_result: None, + refresh_banner: None, } } @@ -296,11 +298,14 @@ impl PurchaseTokenScreen { match dialog.show(ui).inner.dialog_response { Some(ConfirmationStatus::Confirmed) => { self.confirmation_dialog = None; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .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![ @@ -361,6 +366,9 @@ impl ScreenLike for PurchaseTokenScreen { } } BackendTaskSuccessResult::PurchasedTokens(fee_result) => { + if let Some(h) = self.refresh_banner.take() { + h.clear(); + } self.completed_fee_result = Some(fee_result); self.status = PurchaseTokensStatus::Complete; } @@ -371,6 +379,9 @@ impl ScreenLike for PurchaseTokenScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. if let MessageType::Error = message_type { + if let Some(h) = self.refresh_banner.take() { + h.clear(); + } self.status = PurchaseTokensStatus::Error; } } @@ -645,13 +656,8 @@ impl ScreenLike for PurchaseTokenScreen { PurchaseTokensStatus::NotStarted => { // no-op } - PurchaseTokensStatus::WaitingForResult(start_time) => { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .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::Error => { // Error display is handled by the global MessageBanner diff --git a/src/ui/tokens/freeze_tokens_screen.rs b/src/ui/tokens/freeze_tokens_screen.rs index f6f0f0051..90b1485ad 100644 --- a/src/ui/tokens/freeze_tokens_screen.rs +++ b/src/ui/tokens/freeze_tokens_screen.rs @@ -6,7 +6,6 @@ use crate::context::AppContext; 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::component_trait::Component; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::identity_selector::IdentitySelector; @@ -17,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::{BannerHandle, MessageBanner}; 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; @@ -38,13 +38,12 @@ 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), + WaitingForResult, Error, Complete, } @@ -78,6 +77,8 @@ 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 { @@ -208,6 +209,7 @@ impl FreezeTokensScreen { wallet_unlock_popup: WalletUnlockPopup::new(), known_identities, completed_fee_result: None, + refresh_banner: None, } } @@ -272,11 +274,14 @@ impl FreezeTokensScreen { } let freeze_id = parsed.unwrap(); - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .as_secs(); - self.status = FreezeTokensStatus::WaitingForResult(now); + 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,12 +336,18 @@ impl ScreenLike for FreezeTokensScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. if let MessageType::Error = message_type { + if let Some(h) = self.refresh_banner.take() { + h.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 { + if let Some(h) = self.refresh_banner.take() { + h.clear(); + } self.completed_fee_result = Some(fee_result); self.status = FreezeTokensStatus::Complete; } @@ -617,13 +628,8 @@ impl ScreenLike for FreezeTokensScreen { FreezeTokensStatus::NotStarted => { // no-op } - FreezeTokensStatus::WaitingForResult(start_time) => { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .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::Error => { // Error display is handled by the global MessageBanner diff --git a/src/ui/tokens/mint_tokens_screen.rs b/src/ui/tokens/mint_tokens_screen.rs index 3e96e83a1..3924766e6 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,6 +18,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}; 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; @@ -42,13 +42,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, } @@ -81,6 +79,8 @@ 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 { @@ -212,6 +212,7 @@ impl MintTokensScreen { selected_wallet, wallet_unlock_popup: WalletUnlockPopup::new(), completed_fee_result: None, + refresh_banner: None, } } @@ -226,7 +227,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, }; @@ -310,11 +311,14 @@ impl MintTokensScreen { } let receiver_id = parsed_receiver_id.unwrap(); - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .as_secs(); - self.status = MintTokensStatus::WaitingForResult(now); + 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()); @@ -367,12 +371,18 @@ impl ScreenLike for MintTokensScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. if let MessageType::Error = message_type { + if let Some(h) = self.refresh_banner.take() { + h.clear(); + } self.status = MintTokensStatus::Error; } } fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { if let BackendTaskSuccessResult::MintedTokens(fee_result) = backend_task_success_result { + if let Some(h) = self.refresh_banner.take() { + h.clear(); + } self.completed_fee_result = Some(fee_result); self.status = MintTokensStatus::Complete; } @@ -704,16 +714,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) - .expect("Time went backwards") - .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/pause_tokens_screen.rs b/src/ui/tokens/pause_tokens_screen.rs index 1e1cd354d..967312a4d 100644 --- a/src/ui/tokens/pause_tokens_screen.rs +++ b/src/ui/tokens/pause_tokens_screen.rs @@ -7,7 +7,6 @@ use crate::model::fee_estimation::format_credits_as_dash; 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::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; @@ -16,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}; 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; @@ -37,13 +37,12 @@ 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), + WaitingForResult, Error, Complete, } @@ -72,6 +71,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 { @@ -196,6 +197,7 @@ impl PauseTokensScreen { selected_wallet, wallet_unlock_popup: WalletUnlockPopup::new(), completed_fee_result: None, + refresh_banner: None, } } @@ -210,11 +212,14 @@ impl PauseTokensScreen { match dialog.show(ui).inner.dialog_response { Some(ConfirmationStatus::Confirmed) => { self.confirmation_dialog = None; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .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 = @@ -274,12 +279,18 @@ impl ScreenLike for PauseTokensScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. if let MessageType::Error = message_type { + if let Some(h) = self.refresh_banner.take() { + h.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 { + if let Some(h) = self.refresh_banner.take() { + h.clear(); + } self.completed_fee_result = Some(fee_result); self.status = PauseTokensStatus::Complete; } @@ -518,13 +529,8 @@ 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() - .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::Error => { // Error display is handled by the global MessageBanner diff --git a/src/ui/tokens/resume_tokens_screen.rs b/src/ui/tokens/resume_tokens_screen.rs index 9a332207c..2cc9bd646 100644 --- a/src/ui/tokens/resume_tokens_screen.rs +++ b/src/ui/tokens/resume_tokens_screen.rs @@ -7,7 +7,6 @@ use crate::model::fee_estimation::format_credits_as_dash; 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::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; @@ -16,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}; 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; @@ -37,13 +37,12 @@ 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), + WaitingForResult, Error, Complete, } @@ -71,6 +70,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 { @@ -195,6 +196,7 @@ impl ResumeTokensScreen { selected_wallet, wallet_unlock_popup: WalletUnlockPopup::new(), completed_fee_result: None, + refresh_banner: None, } } @@ -210,11 +212,14 @@ impl ResumeTokensScreen { match dialog.show(ui).inner.dialog_response { Some(ConfirmationStatus::Confirmed) => { self.confirmation_dialog = None; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .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 = @@ -274,12 +279,18 @@ impl ScreenLike for ResumeTokensScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. if let MessageType::Error = message_type { + if let Some(h) = self.refresh_banner.take() { + h.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 { + if let Some(h) = self.refresh_banner.take() { + h.clear(); + } self.completed_fee_result = Some(fee_result); self.status = ResumeTokensStatus::Complete; } @@ -518,13 +529,8 @@ 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() - .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::Error => { // Error display is handled by the global MessageBanner diff --git a/src/ui/tokens/set_token_price_screen.rs b/src/ui/tokens/set_token_price_screen.rs index 4e6879508..08eb71b17 100644 --- a/src/ui/tokens/set_token_price_screen.rs +++ b/src/ui/tokens/set_token_price_screen.rs @@ -7,7 +7,6 @@ use crate::model::amount::{Amount, DASH_DECIMAL_PLACES}; use crate::model::fee_estimation::format_credits_as_dash; use crate::model::wallet::Wallet; use crate::ui::components::ComponentResponse; -use crate::ui::components::MessageBanner; use crate::ui::components::amount_input::AmountInput; use crate::ui::components::component_trait::Component; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; @@ -18,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}; use crate::ui::helpers::{TransactionType, add_key_chooser}; use crate::ui::identities::get_selected_wallet; use crate::ui::identities::keys::add_key_screen::AddKeyScreen; @@ -44,7 +44,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)] @@ -76,7 +75,7 @@ impl From> for PricingType { #[derive(PartialEq)] pub enum SetTokenPriceStatus { NotStarted, - WaitingForResult(u64), // Use seconds or millis + WaitingForResult, Error, Complete, } @@ -115,6 +114,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 @@ -285,6 +286,7 @@ impl SetTokenPriceScreen { selected_wallet, wallet_unlock_popup: WalletUnlockPopup::new(), completed_fee_result: None, + refresh_banner: None, } } @@ -727,11 +729,14 @@ impl SetTokenPriceScreen { }; // Set waiting state - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .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() { @@ -822,12 +827,18 @@ impl ScreenLike for SetTokenPriceScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. if let MessageType::Error = message_type { + if let Some(h) = self.refresh_banner.take() { + h.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 { + if let Some(h) = self.refresh_banner.take() { + h.clear(); + } self.completed_fee_result = Some(fee_result); self.status = SetTokenPriceStatus::Complete; } @@ -1113,7 +1124,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 @@ -1144,13 +1155,8 @@ impl ScreenLike for SetTokenPriceScreen { SetTokenPriceStatus::NotStarted => { // no-op } - SetTokenPriceStatus::WaitingForResult(start_time) => { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .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::Error => { // Error display is handled by the global MessageBanner diff --git a/src/ui/tokens/tokens_screen/keyword_search.rs b/src/ui/tokens/tokens_screen/keyword_search.rs index 25603eefd..b5d21af28 100644 --- a/src/ui/tokens/tokens_screen/keyword_search.rs +++ b/src/ui/tokens/tokens_screen/keyword_search.rs @@ -2,11 +2,12 @@ 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::{RichText, Ui}; @@ -59,8 +60,14 @@ 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; + 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 +110,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 diff --git a/src/ui/tokens/tokens_screen/mod.rs b/src/ui/tokens/tokens_screen/mod.rs index 8e2fb8000..484bb9379 100644 --- a/src/ui/tokens/tokens_screen/mod.rs +++ b/src/ui/tokens/tokens_screen/mod.rs @@ -65,6 +65,7 @@ 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; 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; @@ -185,15 +186,15 @@ 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, Error, } @@ -202,7 +203,7 @@ pub enum ContractSearchStatus { pub enum TokenCreatorStatus { #[default] NotStarted, - WaitingForResult(u64), + WaitingForResult, Complete, Error, } @@ -1384,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>, @@ -1759,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, @@ -2492,14 +2499,16 @@ impl TokensScreen { 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()); - MessageBanner::set_global( + 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 @@ -2519,8 +2528,14 @@ 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; + 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 // if the user is on page N, and we have a nextCursor in next_cursors[N - 1] or so @@ -2542,8 +2557,14 @@ 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; + 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 if let Some(prev_cursor) = self.previous_cursors.pop() { @@ -2917,19 +2938,7 @@ impl ScreenLike for TokensScreen { } } - // Show refreshing indicator - 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 - } - // Message display is handled by the global MessageBanner + // 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); @@ -2956,8 +2965,11 @@ 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.refreshing_status = RefreshingStatus::Refreshing; + 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; @@ -3008,6 +3020,11 @@ impl ScreenLike for TokensScreen { } fn display_message(&mut self, msg: &str, msg_type: MessageType) { + // Clear any active operation banner + if let Some(h) = self.operation_banner.take() { + h.clear(); + } + // Banner display is handled globally by AppState; this is only for side-effects. // Reset contract details loading on any error @@ -3062,6 +3079,11 @@ impl ScreenLike for TokensScreen { } 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 72c176736..ce1ad4ed5 100644 --- a/src/ui/tokens/tokens_screen/my_tokens.rs +++ b/src/ui/tokens/tokens_screen/my_tokens.rs @@ -23,7 +23,7 @@ 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::{MessageType, Screen, ScreenType}; -use chrono::{Local, Utc}; +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; @@ -277,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; } } @@ -472,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); } }) }); @@ -485,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); } } diff --git a/src/ui/tokens/tokens_screen/token_creator.rs b/src/ui/tokens/tokens_screen/token_creator.rs index 9cc58d05e..6d72450a5 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; @@ -954,21 +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)); - }); - } - - // Error messages are displayed by the global MessageBanner + // Elapsed display for token creation is handled by the global MessageBanner }); // Close the ScrollArea from line 40 @@ -1504,8 +1489,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 c5678f2e1..428c889d8 100644 --- a/src/ui/tokens/transfer_tokens_screen.rs +++ b/src/ui/tokens/transfer_tokens_screen.rs @@ -6,7 +6,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}; @@ -18,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}; 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; @@ -25,14 +25,13 @@ 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; @@ -41,7 +40,7 @@ use super::tokens_screen::IdentityTokenBalance; #[derive(PartialEq)] pub enum TransferTokensStatus { NotStarted, - WaitingForResult(TimestampMillis), + WaitingForResult, Error, Complete, } @@ -64,6 +63,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 { @@ -113,6 +114,7 @@ impl TransferTokensScreen { selected_wallet, wallet_unlock_popup: WalletUnlockPopup::new(), completed_fee_result: None, + refresh_banner: None, } } @@ -142,7 +144,7 @@ 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::WaitingForResult | TransferTokensStatus::Complete => false, TransferTokensStatus::NotStarted | TransferTokensStatus::Error => { amount_input.set_max_amount(Some(self.max_amount.value())); true @@ -225,11 +227,14 @@ impl TransferTokensScreen { } let receiver_id = parsed_receiver_id.unwrap(); - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .as_secs(); - self.transfer_tokens_status = TransferTokensStatus::WaitingForResult(now); + 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); let data_contract = Arc::new( self.app_context @@ -264,6 +269,9 @@ impl ScreenLike for TransferTokensScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. if let MessageType::Error = message_type { + if let Some(h) = self.refresh_banner.take() { + h.clear(); + } self.transfer_tokens_status = TransferTokensStatus::Error; } } @@ -271,6 +279,9 @@ impl ScreenLike for TransferTokensScreen { fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { if let BackendTaskSuccessResult::TransferredTokens(fee_result) = backend_task_success_result { + if let Some(h) = self.refresh_banner.take() { + h.clear(); + } self.completed_fee_result = Some(fee_result); self.transfer_tokens_status = TransferTokensStatus::Complete; } @@ -558,35 +569,8 @@ impl ScreenLike for TransferTokensScreen { TransferTokensStatus::NotStarted => { // Do nothing } - TransferTokensStatus::WaitingForResult(start_time) => { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .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::Error => { // Error display is handled by the global MessageBanner diff --git a/src/ui/tokens/unfreeze_tokens_screen.rs b/src/ui/tokens/unfreeze_tokens_screen.rs index 4f9a05beb..6b1a3ec2e 100644 --- a/src/ui/tokens/unfreeze_tokens_screen.rs +++ b/src/ui/tokens/unfreeze_tokens_screen.rs @@ -6,7 +6,6 @@ use crate::context::AppContext; 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::component_trait::Component; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::identity_selector::IdentitySelector; @@ -17,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::{BannerHandle, MessageBanner}; 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; @@ -39,13 +39,12 @@ 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), + WaitingForResult, Error, Complete, } @@ -82,6 +81,8 @@ 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 { @@ -213,6 +214,7 @@ impl UnfreezeTokensScreen { wallet_unlock_popup: WalletUnlockPopup::new(), frozen_identities, completed_fee_result: None, + refresh_banner: None, } } @@ -274,11 +276,14 @@ impl UnfreezeTokensScreen { } let unfreeze_id = parsed.unwrap(); - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .as_secs(); - self.status = UnfreezeTokensStatus::WaitingForResult(now); + 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,12 +339,18 @@ impl ScreenLike for UnfreezeTokensScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. if let MessageType::Error = message_type { + if let Some(h) = self.refresh_banner.take() { + h.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 { + if let Some(h) = self.refresh_banner.take() { + h.clear(); + } self.completed_fee_result = Some(fee_result); self.status = UnfreezeTokensStatus::Complete; } @@ -606,13 +617,8 @@ impl ScreenLike for UnfreezeTokensScreen { UnfreezeTokensStatus::NotStarted => { // no-op } - UnfreezeTokensStatus::WaitingForResult(start_time) => { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .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::Error => { // Error display is handled by the global MessageBanner diff --git a/src/ui/tools/transition_visualizer_screen.rs b/src/ui/tools/transition_visualizer_screen.rs index d43440afc..61c71d71b 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}; 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,17 @@ impl TransitionVisualizerScreen { if ui.add(button).clicked() { // Mark as submitting - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .as_secs(); - self.broadcast_status = TransitionBroadcastStatus::Submitting(now); + if let Some(h) = self.submit_banner.take() { + h.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 +270,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) - .expect("Time went backwards") - .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(); @@ -407,14 +388,17 @@ impl ScreenLike for TransitionVisualizerScreen { // Banner display is handled globally by AppState; this is only for side-effects. match message_type { MessageType::Success => { - if matches!( - self.broadcast_status, - TransitionBroadcastStatus::Submitting(_) - ) { + if matches!(self.broadcast_status, TransitionBroadcastStatus::Submitting) { + if let Some(h) = self.submit_banner.take() { + h.clear(); + } self.broadcast_status = TransitionBroadcastStatus::Complete(Instant::now()); } } MessageType::Error | MessageType::Warning => { + if let Some(h) = self.submit_banner.take() { + h.clear(); + } self.broadcast_status = TransitionBroadcastStatus::NotStarted; } MessageType::Info => {} diff --git a/src/ui/wallets/send_screen.rs b/src/ui/wallets/send_screen.rs index e138592be..e4fc57a28 100644 --- a/src/ui/wallets/send_screen.rs +++ b/src/ui/wallets/send_screen.rs @@ -6,7 +6,6 @@ use crate::context::AppContext; use crate::model::amount::{Amount, DASH_DECIMAL_PLACES}; use crate::model::fee_estimation::format_credits_as_dash; use crate::model::wallet::{Wallet, WalletSeedHash}; -use crate::ui::components::MessageBanner; use crate::ui::components::amount_input::AmountInput; use crate::ui::components::component_trait::{Component, ComponentResponse}; use crate::ui::components::left_panel::add_left_panel; @@ -15,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}; use crate::ui::theme::DashColors; use crate::ui::{MessageType, RootScreenType, ScreenLike}; use dash_sdk::dashcore_rpc::dashcore::Address; @@ -35,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; @@ -284,8 +283,8 @@ 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 (message displayed by global MessageBanner) @@ -390,6 +389,7 @@ pub struct WalletSendScreen { // State send_status: SendStatus, + send_banner: Option, // Wallet unlock wallet_unlock_popup: WalletUnlockPopup, @@ -417,6 +417,7 @@ impl WalletSendScreen { fee_strategy: PlatformFeeStrategy::default(), subtract_fee: false, send_status: SendStatus::NotStarted, + send_banner: None, wallet_unlock_popup: WalletUnlockPopup::new(), } } @@ -481,15 +482,8 @@ impl WalletSendScreen { self.send_status = SendStatus::NotStarted; } - fn now_epoch_secs() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .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 { @@ -1077,28 +1071,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; @@ -1120,20 +1093,12 @@ 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) @@ -1668,7 +1633,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| { @@ -1696,6 +1661,16 @@ impl WalletSendScreen { if ui.add_enabled(can_send, send_button).clicked() { match self.validate_and_send() { Ok(send_action) => { + if let Some(h) = self.send_banner.take() { + h.clear(); + } + let handle = MessageBanner::set_global( + ui.ctx(), + "Sending transaction...", + MessageType::Info, + ); + handle.with_elapsed(); + self.send_banner = Some(handle); action = send_action; } Err(e) => { @@ -2228,7 +2203,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 { @@ -2273,6 +2248,16 @@ impl WalletSendScreen { if ui.add_enabled(can_send, send_button).clicked() { match self.validate_and_send_advanced() { Ok(send_action) => { + if let Some(h) = self.send_banner.take() { + h.clear(); + } + let handle = MessageBanner::set_global( + ui.ctx(), + "Sending transaction...", + MessageType::Info, + ); + handle.with_elapsed(); + self.send_banner = Some(handle); action = send_action; } Err(e) => { @@ -2611,7 +2596,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; } @@ -2661,9 +2646,15 @@ impl ScreenLike for WalletSendScreen { // Banner display is handled globally by AppState; this is only for side-effects. match message_type { MessageType::Error | MessageType::Warning => { + if let Some(h) = self.send_banner.take() { + h.clear(); + } self.send_status = SendStatus::Error; } MessageType::Success => { + if let Some(h) = self.send_banner.take() { + h.clear(); + } self.send_status = SendStatus::Complete(message.to_string()); } MessageType::Info => { @@ -2676,6 +2667,9 @@ impl ScreenLike for WalletSendScreen { &mut self, backend_task_success_result: crate::backend_task::BackendTaskSuccessResult, ) { + if let Some(h) = self.send_banner.take() { + h.clear(); + } match backend_task_success_result { crate::backend_task::BackendTaskSuccessResult::WalletPayment { txid: _, From e0f0ce9ceb7d4d027801ca8c2fe301fc5fcb16e5 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 18 Feb 2026 21:22:56 +0100 Subject: [PATCH 12/46] fix: start elapsed counter at 1s instead of 0s Aligns elapsed display with the countdown timer which already adds 1 to avoid showing "0s" immediately. Co-Authored-By: Claude Opus 4.6 --- src/ui/components/message_banner.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/components/message_banner.rs b/src/ui/components/message_banner.rs index 476f9c6d3..241f93c93 100644 --- a/src/ui/components/message_banner.rs +++ b/src/ui/components/message_banner.rs @@ -375,7 +375,7 @@ fn process_banner(ui: &mut egui::Ui, state: &BannerState) -> BannerStatus { // Compute the right-side time annotation let annotation = if state.show_elapsed { - Some(format!("({}s)", elapsed.as_secs())) + Some(format!("({}s)", elapsed.as_secs() + 1)) } else if let Some(duration) = state.auto_dismiss_after { let remaining = duration.saturating_sub(elapsed); Some(format!("({}s)", remaining.as_secs() + 1)) From 1fd3e4b5ffc3e494bb70658e81854f8cd8cace13 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 18 Feb 2026 22:05:41 +0100 Subject: [PATCH 13/46] chore: rabbit review --- .../2026-02-17-unified-messages/TODO.md | 13 ++++++++++++ .../architecture.md | 6 +++--- .../message-banner-ux-spec.md | 4 ++-- .../contracts_documents_screen.rs | 3 +++ .../register_contract_screen.rs | 4 ++++ .../update_contract_screen.rs | 9 +++++---- src/ui/dashpay/contact_requests.rs | 2 +- src/ui/dashpay/contacts_list.rs | 6 ++++++ src/ui/dashpay/profile_search.rs | 20 ++++++++++++------- src/ui/dashpay/qr_scanner.rs | 1 + src/ui/identities/keys/add_key_screen.rs | 20 +++++++++---------- src/ui/tokens/add_token_by_id_screen.rs | 5 +++-- src/ui/tokens/pause_tokens_screen.rs | 6 +++--- src/ui/tokens/resume_tokens_screen.rs | 2 +- src/ui/tokens/tokens_screen/keyword_search.rs | 3 +++ src/ui/tokens/tokens_screen/mod.rs | 16 +++++++++++++-- src/ui/tokens/unfreeze_tokens_screen.rs | 6 +++--- 17 files changed, 87 insertions(+), 39 deletions(-) create mode 100644 docs/ai-design/2026-02-17-unified-messages/TODO.md diff --git a/docs/ai-design/2026-02-17-unified-messages/TODO.md b/docs/ai-design/2026-02-17-unified-messages/TODO.md new file mode 100644 index 000000000..fa854b19e --- /dev/null +++ b/docs/ai-design/2026-02-17-unified-messages/TODO.md @@ -0,0 +1,13 @@ +# Unified messages TODO + +1. fn display_message right now in many cases only sets some screen-scoped field to indicate error state: + ``` + fn display_message(&mut self, _message: &str, message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. + if let MessageType::Error = message_type { + self.transfer_tokens_status = TransferTokensStatus::Error; + } + } + ``` + + Check if we have any good way to unify that. It doesn't sound like something that should be in display_message - rather on_message() or message_handler() diff --git a/docs/ai-design/2026-02-17-unified-messages/architecture.md b/docs/ai-design/2026-02-17-unified-messages/architecture.md index 9b031a8b1..e529d79db 100644 --- a/docs/ai-design/2026-02-17-unified-messages/architecture.md +++ b/docs/ai-design/2026-02-17-unified-messages/architecture.md @@ -124,7 +124,7 @@ The `Component` trait implementation: Both global and per-instance paths call `render_banner()`: -``` +```rust render_banner(ui, text, message_type, annotation: Option<&str>) -> bool (dismissed?) ``` @@ -132,7 +132,7 @@ The `annotation` parameter is generic — it receives either a countdown string ### Visual Structure -``` +```text +-----------------------------------------------------------------------+ | [Icon] Message text here [5s] [x] | +-----------------------------------------------------------------------+ @@ -153,7 +153,7 @@ All colors are resolved via `DashColors` methods — zero hardcoded `Color32` va `AppState::update()` in `src/app.rs` sets global banners automatically for all task results: -``` +```text TaskResult::Error(message) → MessageBanner::set_global(ctx, &message, MessageType::Error) → screen.display_message(&message, MessageType::Error) // for side-effects diff --git a/docs/ai-design/2026-02-17-unified-messages/message-banner-ux-spec.md b/docs/ai-design/2026-02-17-unified-messages/message-banner-ux-spec.md index cd325a0cd..89f657ce8 100644 --- a/docs/ai-design/2026-02-17-unified-messages/message-banner-ux-spec.md +++ b/docs/ai-design/2026-02-17-unified-messages/message-banner-ux-spec.md @@ -28,7 +28,7 @@ All four types display a dismiss button (`x`). Success and Info also show a coun ### 3.1 Layout Structure -``` +```text +-----------------------------------------------------------------------+ | [Icon] Message text here [5s] [x] | +-----------------------------------------------------------------------+ @@ -78,7 +78,7 @@ Background uses low alpha (8% light, 12% dark) for subtle tinting. Border uses ` Global banners render at the top of the content area inside `island_central_panel()`, before any screen content: -``` +```text +--------------------------------------------------+ | Top Panel (header / navigation) | +--------------------------------------------------+ diff --git a/src/ui/contracts_documents/contracts_documents_screen.rs b/src/ui/contracts_documents/contracts_documents_screen.rs index 44b1a7e4d..bc5c6c759 100644 --- a/src/ui/contracts_documents/contracts_documents_screen.rs +++ b/src/ui/contracts_documents/contracts_documents_screen.rs @@ -210,6 +210,9 @@ impl DocumentQueryScreen { ))); } Err(e) => { + if let Some(h) = self.query_banner.take() { + h.clear(); + } self.document_query_status = DocumentQueryStatus::Error; MessageBanner::set_global( ui.ctx(), diff --git a/src/ui/contracts_documents/register_contract_screen.rs b/src/ui/contracts_documents/register_contract_screen.rs index d6f7ea3ae..3271b0a3d 100644 --- a/src/ui/contracts_documents/register_contract_screen.rs +++ b/src/ui/contracts_documents/register_contract_screen.rs @@ -71,6 +71,10 @@ impl RegisterDataContractScreen { None }; + if let Some(err) = error_message { + MessageBanner::set_global(app_context.egui_ctx(), &err, MessageType::Error); + } + // Auto-select a suitable key for contract registration use dash_sdk::dpp::identity::KeyType; let selected_key = selected_qualified_identity.as_ref().and_then(|identity| { diff --git a/src/ui/contracts_documents/update_contract_screen.rs b/src/ui/contracts_documents/update_contract_screen.rs index dcc5e8c3b..465303b79 100644 --- a/src/ui/contracts_documents/update_contract_screen.rs +++ b/src/ui/contracts_documents/update_contract_screen.rs @@ -40,7 +40,6 @@ enum BroadcastStatus { FetchingNonce(u64), Broadcasting(u64), ProofError(u64), - BroadcastError, Done, } @@ -217,8 +216,8 @@ impl UpdateDataContractScreen { match &self.broadcast_status { BroadcastStatus::Idle => {} - BroadcastStatus::ParsingError(_) | BroadcastStatus::BroadcastError => { - // Parsing errors shown via render_error_bubble; broadcast errors via global banner + BroadcastStatus::ParsingError(_) => { + // Parsing errors shown via render_error_bubble } BroadcastStatus::ValidContract(contract) => { // Fee estimation display - contract updates charge registration fees for the new contract @@ -366,7 +365,9 @@ impl ScreenLike for UpdateDataContractScreen { if message.contains("proof error logged, contract inserted into the database") { self.broadcast_status = BroadcastStatus::Done; } else { - self.broadcast_status = BroadcastStatus::BroadcastError; + // Re-parse the contract so ValidContract state is restored + // and the user can retry broadcasting. + self.parse_contract(); } } } diff --git a/src/ui/dashpay/contact_requests.rs b/src/ui/dashpay/contact_requests.rs index f3d169b4b..132b5ae3e 100644 --- a/src/ui/dashpay/contact_requests.rs +++ b/src/ui/dashpay/contact_requests.rs @@ -748,7 +748,7 @@ impl ContactRequests { |ui| { if ui.button("Cancel").clicked() { // TODO: Cancel outgoing request - crate::ui::components::MessageBanner::set_global(ui.ctx(), "Request cancelled", MessageType::Info); + crate::ui::components::MessageBanner::set_global(ui.ctx(), "Cancel request not implemented yet", MessageType::Warning); } }, ); diff --git a/src/ui/dashpay/contacts_list.rs b/src/ui/dashpay/contacts_list.rs index 110a951d3..421575902 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; @@ -911,6 +912,11 @@ impl ContactsList { "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) = diff --git a/src/ui/dashpay/profile_search.rs b/src/ui/dashpay/profile_search.rs index 71e3851d8..9f10f23ac 100644 --- a/src/ui/dashpay/profile_search.rs +++ b/src/ui/dashpay/profile_search.rs @@ -51,9 +51,14 @@ impl ProfileSearchScreen { } } - 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; } @@ -68,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, ); @@ -128,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()); } }); @@ -219,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); diff --git a/src/ui/dashpay/qr_scanner.rs b/src/ui/dashpay/qr_scanner.rs index db6ad9716..5fe6ffe40 100644 --- a/src/ui/dashpay/qr_scanner.rs +++ b/src/ui/dashpay/qr_scanner.rs @@ -49,6 +49,7 @@ impl QRScannerScreen { fn parse_qr_code(&mut self) { if self.qr_data_input.is_empty() { + self.parsed_qr_data = None; crate::ui::components::MessageBanner::set_global( self.app_context.egui_ctx(), "Please enter QR code data", diff --git a/src/ui/identities/keys/add_key_screen.rs b/src/ui/identities/keys/add_key_screen.rs index 381b75e00..0053d83b0 100644 --- a/src/ui/identities/keys/add_key_screen.rs +++ b/src/ui/identities/keys/add_key_screen.rs @@ -407,11 +407,6 @@ 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 { @@ -676,12 +671,15 @@ impl ScreenLike for AddKeyScreen { .frame(true) .corner_radius(3.0); if ui.add(button).clicked() { - 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 |= self.validate_and_add_key(); + 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 diff --git a/src/ui/tokens/add_token_by_id_screen.rs b/src/ui/tokens/add_token_by_id_screen.rs index f2bdc0ba1..5a7f3a1b6 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; @@ -370,8 +371,8 @@ impl ScreenLike for AddTokenByIdScreen { ui.add_space(10.0); self.render_search_results(ui); - if let AddTokenStatus::Error(_) = &self.status { - // Error display is handled by the global MessageBanner + if let AddTokenStatus::Error(msg) = &self.status { + MessageBanner::set_global(ui.ctx(), msg, MessageType::Error); } ui.add_space(10.0); diff --git a/src/ui/tokens/pause_tokens_screen.rs b/src/ui/tokens/pause_tokens_screen.rs index 967312a4d..bb736a4bb 100644 --- a/src/ui/tokens/pause_tokens_screen.rs +++ b/src/ui/tokens/pause_tokens_screen.rs @@ -98,7 +98,7 @@ impl PauseTokensScreen { .authorized_to_make_change_action_takers() { AuthorizedActionTakers::NoOne => { - set_error_banner("Burning is not allowed on this token"); + set_error_banner("Pausing is not allowed on this token"); None } AuthorizedActionTakers::ContractOwner => { @@ -106,14 +106,14 @@ impl PauseTokensScreen { != identity_token_info.identity.identity.id() { set_error_banner( - "You are not allowed to burn this token. Only the contract owner is.", + "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() { - set_error_banner("You are not allowed to burn this token"); + set_error_banner("You are not allowed to pause this token"); } None } diff --git a/src/ui/tokens/resume_tokens_screen.rs b/src/ui/tokens/resume_tokens_screen.rs index 2cc9bd646..32824ef65 100644 --- a/src/ui/tokens/resume_tokens_screen.rs +++ b/src/ui/tokens/resume_tokens_screen.rs @@ -278,7 +278,7 @@ impl ResumeTokensScreen { impl ScreenLike for ResumeTokensScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. - if let MessageType::Error = message_type { + if matches!(message_type, MessageType::Error | MessageType::Warning) { if let Some(h) = self.refresh_banner.take() { h.clear(); } diff --git a/src/ui/tokens/tokens_screen/keyword_search.rs b/src/ui/tokens/tokens_screen/keyword_search.rs index b5d21af28..5a1109b09 100644 --- a/src/ui/tokens/tokens_screen/keyword_search.rs +++ b/src/ui/tokens/tokens_screen/keyword_search.rs @@ -61,6 +61,9 @@ impl TokensScreen { // Clear old results, set status self.search_results.lock().unwrap().clear(); 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...", diff --git a/src/ui/tokens/tokens_screen/mod.rs b/src/ui/tokens/tokens_screen/mod.rs index 484bb9379..103e9e367 100644 --- a/src/ui/tokens/tokens_screen/mod.rs +++ b/src/ui/tokens/tokens_screen/mod.rs @@ -2502,6 +2502,9 @@ impl TokensScreen { // Set adding status self.adding_token_start_time = Some(Utc::now()); self.adding_token_name = Some(token_info.token_name.clone()); + if let Some(h) = self.operation_banner.take() { + h.clear(); + } let handle = MessageBanner::set_global( self.app_context.egui_ctx(), "Adding token...", @@ -2529,6 +2532,9 @@ impl TokensScreen { if let Some(next_cursor) = self.next_cursors.last().cloned() { // set status 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...", @@ -2537,7 +2543,7 @@ impl TokensScreen { 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()); @@ -2558,6 +2564,9 @@ impl TokensScreen { // Move to (page - 1) self.search_current_page -= 1; 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...", @@ -2566,7 +2575,7 @@ impl TokensScreen { 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); @@ -2966,6 +2975,9 @@ impl ScreenLike for TokensScreen { if matches!(token_task.as_ref(), TokenTask::QueryMyTokenBalances) => { 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(); diff --git a/src/ui/tokens/unfreeze_tokens_screen.rs b/src/ui/tokens/unfreeze_tokens_screen.rs index 6b1a3ec2e..6dec8e34c 100644 --- a/src/ui/tokens/unfreeze_tokens_screen.rs +++ b/src/ui/tokens/unfreeze_tokens_screen.rs @@ -113,7 +113,7 @@ impl UnfreezeTokensScreen { .authorized_to_make_change_action_takers() { AuthorizedActionTakers::NoOne => { - set_error_banner("Burning is not allowed on this token"); + set_error_banner("Unfreezing is not allowed on this token"); None } AuthorizedActionTakers::ContractOwner => { @@ -121,14 +121,14 @@ impl UnfreezeTokensScreen { != identity_token_info.identity.identity.id() { set_error_banner( - "You are not allowed to burn this token. Only the contract owner is.", + "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() { - set_error_banner("You are not allowed to burn this token"); + set_error_banner("You are not allowed to unfreeze this token"); } None } From eac9bf1ab8748351389239225d6d3afd0d6ba943 Mon Sep 17 00:00:00 2001 From: lklimek <842586+lklimek@users.noreply.github.com> Date: Wed, 18 Feb 2026 22:08:20 +0100 Subject: [PATCH 14/46] Update src/app.rs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/app.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app.rs b/src/app.rs index b09102ba6..5cc7f5edd 100644 --- a/src/app.rs +++ b/src/app.rs @@ -859,7 +859,8 @@ impl App for AppState { self.visible_screen_mut().refresh(); } _ => { - MessageBanner::set_global(ctx, "Success", MessageType::Success); + // For all other success results, let the screen decide how to display + // the outcome without showing a generic global success banner. self.visible_screen_mut() .display_task_result(unboxed_message); } From 8f80a07f61fb37a54f2afefa33af62d786f9a1a1 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 18 Feb 2026 22:15:09 +0100 Subject: [PATCH 15/46] chore: peer review --- doc/COMPONENT_DESIGN_PATTERN.md | 6 ++++-- docs/ai-design/2026-02-17-unified-messages/architecture.md | 6 +++--- .../2026-02-17-unified-messages/message-banner-ux-spec.md | 4 ++-- src/ui/identities/identities_screen.rs | 2 +- src/ui/identities/top_up_identity_screen/mod.rs | 2 +- src/ui/tokens/mint_tokens_screen.rs | 2 +- 6 files changed, 12 insertions(+), 10 deletions(-) diff --git a/doc/COMPONENT_DESIGN_PATTERN.md b/doc/COMPONENT_DESIGN_PATTERN.md index 0c247a073..60e93bcf5 100644 --- a/doc/COMPONENT_DESIGN_PATTERN.md +++ b/doc/COMPONENT_DESIGN_PATTERN.md @@ -103,11 +103,13 @@ See `AmountInput` in `src/ui/components/amount_input.rs` for a complete example. ## Display-Only Components -Not all components produce domain values. **Display-only** components like `MessageBanner`, `StyledButton`, `GradientHeading`, and `InfoPopup` consume data for rendering and do not implement the `Component` trait. They follow the same structural conventions (private fields, `new()` constructor, builder methods, `show()`) but omit `ComponentResponse` and `update()`. +Not all components produce domain values. **Display-only** components like `StyledButton`, `GradientHeading`, and `InfoPopup` consume data for rendering and do not implement the `Component` trait. They follow the same structural conventions (private fields, `new()` constructor, builder methods, `show()`) but omit `ComponentResponse` and `update()`. + +`MessageBanner` is a **hybrid**: it implements the `Component` trait for per-instance usage (owned by a screen struct), and it also exposes a global API via static methods for app-wide messaging. ### Global State via egui Context -`MessageBanner` demonstrates a pattern for **app-wide UI state** stored in egui context data rather than in a screen struct. This is useful when the same widget must be accessible from multiple call sites (e.g., `AppState::update()` sets it, `island_central_panel()` renders it). +In addition to its per-screen `Component` usage, `MessageBanner` demonstrates a pattern for **app-wide UI state** stored in egui context data rather than in a screen struct. This is useful when the same widget must be accessible from multiple call sites (e.g., `AppState::update()` sets it, `island_central_panel()` renders it). ```rust // Setting from anywhere with &egui::Context diff --git a/docs/ai-design/2026-02-17-unified-messages/architecture.md b/docs/ai-design/2026-02-17-unified-messages/architecture.md index 9b031a8b1..e529d79db 100644 --- a/docs/ai-design/2026-02-17-unified-messages/architecture.md +++ b/docs/ai-design/2026-02-17-unified-messages/architecture.md @@ -124,7 +124,7 @@ The `Component` trait implementation: Both global and per-instance paths call `render_banner()`: -``` +```rust render_banner(ui, text, message_type, annotation: Option<&str>) -> bool (dismissed?) ``` @@ -132,7 +132,7 @@ The `annotation` parameter is generic — it receives either a countdown string ### Visual Structure -``` +```text +-----------------------------------------------------------------------+ | [Icon] Message text here [5s] [x] | +-----------------------------------------------------------------------+ @@ -153,7 +153,7 @@ All colors are resolved via `DashColors` methods — zero hardcoded `Color32` va `AppState::update()` in `src/app.rs` sets global banners automatically for all task results: -``` +```text TaskResult::Error(message) → MessageBanner::set_global(ctx, &message, MessageType::Error) → screen.display_message(&message, MessageType::Error) // for side-effects diff --git a/docs/ai-design/2026-02-17-unified-messages/message-banner-ux-spec.md b/docs/ai-design/2026-02-17-unified-messages/message-banner-ux-spec.md index cd325a0cd..89f657ce8 100644 --- a/docs/ai-design/2026-02-17-unified-messages/message-banner-ux-spec.md +++ b/docs/ai-design/2026-02-17-unified-messages/message-banner-ux-spec.md @@ -28,7 +28,7 @@ All four types display a dismiss button (`x`). Success and Info also show a coun ### 3.1 Layout Structure -``` +```text +-----------------------------------------------------------------------+ | [Icon] Message text here [5s] [x] | +-----------------------------------------------------------------------+ @@ -78,7 +78,7 @@ Background uses low alpha (8% light, 12% dark) for subtle tinting. Border uses ` Global banners render at the top of the content area inside `island_central_panel()`, before any screen content: -``` +```text +--------------------------------------------------+ | Top Panel (header / navigation) | +--------------------------------------------------+ diff --git a/src/ui/identities/identities_screen.rs b/src/ui/identities/identities_screen.rs index 693663bae..00136501e 100644 --- a/src/ui/identities/identities_screen.rs +++ b/src/ui/identities/identities_screen.rs @@ -1083,7 +1083,7 @@ 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 let MessageType::Error = message_type + if matches!(message_type, MessageType::Error | MessageType::Warning) && let Some(handle) = self.refresh_banner.take() { handle.clear(); diff --git a/src/ui/identities/top_up_identity_screen/mod.rs b/src/ui/identities/top_up_identity_screen/mod.rs index 19b63d588..cd13a469a 100644 --- a/src/ui/identities/top_up_identity_screen/mod.rs +++ b/src/ui/identities/top_up_identity_screen/mod.rs @@ -428,7 +428,7 @@ impl TopUpIdentityScreen { impl ScreenLike for TopUpIdentityScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. - if message_type == MessageType::Error { + if matches!(message_type, MessageType::Error | MessageType::Warning) { // Reset step so UI is not stuck on waiting messages let mut step = self.step.write().unwrap(); if *step == WalletFundedScreenStep::WaitingForPlatformAcceptance diff --git a/src/ui/tokens/mint_tokens_screen.rs b/src/ui/tokens/mint_tokens_screen.rs index 3e96e83a1..a2bf989ad 100644 --- a/src/ui/tokens/mint_tokens_screen.rs +++ b/src/ui/tokens/mint_tokens_screen.rs @@ -366,7 +366,7 @@ impl MintTokensScreen { impl ScreenLike for MintTokensScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. - if let MessageType::Error = message_type { + if matches!(message_type, MessageType::Error | MessageType::Warning) { self.status = MintTokensStatus::Error; } } From 25d0f634298ce32c78c314a3712a4174bbb7aa9d Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Feb 2026 10:36:30 +0100 Subject: [PATCH 16/46] chore: fix build errors --- src/ui/dashpay/contact_details.rs | 1 - src/ui/tokens/tokens_screen/token_creator.rs | 7 ++++-- src/ui/wallets/wallets_screen/mod.rs | 23 +++++++++++++++----- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/ui/dashpay/contact_details.rs b/src/ui/dashpay/contact_details.rs index c2d3dc255..7ba9ad0ec 100644 --- a/src/ui/dashpay/contact_details.rs +++ b/src/ui/dashpay/contact_details.rs @@ -506,7 +506,6 @@ impl ContactDetailsScreen { action } - } impl ScreenLike for ContactDetailsScreen { diff --git a/src/ui/tokens/tokens_screen/token_creator.rs b/src/ui/tokens/tokens_screen/token_creator.rs index a9ec79c4f..2bcc17b41 100644 --- a/src/ui/tokens/tokens_screen/token_creator.rs +++ b/src/ui/tokens/tokens_screen/token_creator.rs @@ -1455,8 +1455,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; } diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index 98fe03a52..55c354234 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -100,6 +100,7 @@ pub struct WalletsBalancesScreen { fund_platform_dialog: FundPlatformAddressDialogState, private_key_dialog: PrivateKeyDialogState, selected_account: Option<(AccountCategory, Option)>, + show_zero_balance_addresses: bool, /// Pending refresh of platform address balances (triggered after transfers) pending_platform_balance_refresh: Option, /// Whether we should refresh the wallet after it's unlocked @@ -190,6 +191,7 @@ impl WalletsBalancesScreen { fund_platform_dialog: FundPlatformAddressDialogState::default(), private_key_dialog: PrivateKeyDialogState::default(), selected_account: None, + show_zero_balance_addresses: false, pending_platform_balance_refresh: None, pending_refresh_after_unlock: false, pending_refresh_mode: RefreshMode::default(), @@ -1137,7 +1139,7 @@ impl WalletsBalancesScreen { let summaries = { let wallet = wallet_arc.read().unwrap(); self.render_wallet_overview(ui, &wallet); - collect_account_summaries(&wallet) + collect_account_summaries(&wallet, self.app_context.network) }; self.ensure_account_selection(&summaries); @@ -1155,10 +1157,21 @@ impl WalletsBalancesScreen { format!("Addresses ({})", category.label(*index)) }) .unwrap_or_else(|| "Addresses".to_string()); - ui.heading( - RichText::new(addresses_heading) - .color(DashColors::text_primary(dark_mode)), - ); + ui.horizontal(|ui| { + ui.heading( + RichText::new(addresses_heading) + .color(DashColors::text_primary(dark_mode)), + ); + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + ui.checkbox( + &mut self.show_zero_balance_addresses, + "Show zero-balance addresses", + ); + }, + ); + }); ui.add_space(8.0); action |= self.render_address_table(ui); From bb510c8ecfe69a68f896e3fdfbba2c3f35b3058e Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 25 Feb 2026 15:58:06 +0100 Subject: [PATCH 17/46] fix(ui): make MessageBanner::set_global truly idempotent set_global() no longer resets timestamps, auto-dismiss timer, or logged flag when a banner with identical text already exists. This makes it safe to call every frame without log spam or timer restarts. Cherry-picked from origin/fix/spv-peer-timeout (08e3b3bf). Co-Authored-By: Claude Opus 4.6 --- src/ui/components/message_banner.rs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/ui/components/message_banner.rs b/src/ui/components/message_banner.rs index b3fe7e6aa..2dc09e61a 100644 --- a/src/ui/components/message_banner.rs +++ b/src/ui/components/message_banner.rs @@ -279,18 +279,26 @@ impl MessageBanner { // for another (e.g., replacing a generic "Success" with a specific one). /// Adds a global banner message if one with the same text does not already exist. + /// + /// **Idempotent**: if a banner with identical text is already displayed, + /// this is a no-op and the existing banner is returned unchanged + /// (timestamps, auto-dismiss timer, and `logged` flag are all preserved). + /// This makes it safe to call every frame without side-effects. + /// + /// To reset the auto-dismiss timer of an existing banner, use + /// [`replace_global`](Self::replace_global) with the same text for both + /// `old_text` and `new_text`, or store the returned [`BannerHandle`] and + /// call [`BannerHandle::with_auto_dismiss`]. + /// /// 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 { let mut banners = get_banners(ctx); - if let Some(existing) = banners.iter_mut().find(|b| b.text == text) { - existing.reset_to(text.to_string(), message_type); - let key = existing.key; - set_banners(ctx, banners); + if let Some(existing) = banners.iter().find(|b| b.text == text) { return BannerHandle { ctx: ctx.clone(), - key, + key: existing.key, }; } let key = next_banner_key(); From 4280cca743a3f8aa27cd5b10a0e82eee55732a28 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 25 Feb 2026 17:23:22 +0100 Subject: [PATCH 18/46] fix(ui): address code review findings R01, R03, R06, R09, R10 - R01: Replace expect("No key selected") with graceful match + error banner in 11 token screens to prevent panics on missing signing key - R03: Remove dead backend_message field from AddExistingIdentityScreen - R06: Replace is_some() + unwrap() with idiomatic if-let-Some pattern in 10 token screens; use is_some_and() in structs.rs - R09: Add use imports for MessageBanner in 5 dashpay screens, replacing 22 fully-qualified crate::ui::components::MessageBanner:: calls - R10: Replace custom_dash_qt_error_message inline rendering with MessageBanner::set_global in network_chooser_screen Co-Authored-By: Claude Opus 4.6 --- src/ui/dashpay/contact_info_editor.rs | 3 +- src/ui/dashpay/profile_screen.rs | 5 +- src/ui/dashpay/qr_code_generator.rs | 15 ++--- src/ui/dashpay/qr_scanner.rs | 15 ++--- src/ui/dashpay/send_payment.rs | 15 ++--- .../add_existing_identity_screen.rs | 12 ---- src/ui/network_chooser_screen.rs | 58 +++++++------------ src/ui/tokens/burn_tokens_screen.rs | 18 +++++- src/ui/tokens/claim_tokens_screen.rs | 14 ++++- src/ui/tokens/destroy_frozen_funds_screen.rs | 18 +++++- src/ui/tokens/freeze_tokens_screen.rs | 18 +++++- src/ui/tokens/mint_tokens_screen.rs | 18 +++++- src/ui/tokens/pause_tokens_screen.rs | 18 +++++- src/ui/tokens/resume_tokens_screen.rs | 18 +++++- src/ui/tokens/set_token_price_screen.rs | 18 +++++- src/ui/tokens/tokens_screen/structs.rs | 2 +- src/ui/tokens/transfer_tokens_screen.rs | 14 ++++- src/ui/tokens/unfreeze_tokens_screen.rs | 18 +++++- src/ui/tokens/update_token_config.rs | 38 +++++++----- 19 files changed, 217 insertions(+), 118 deletions(-) diff --git a/src/ui/dashpay/contact_info_editor.rs b/src/ui/dashpay/contact_info_editor.rs index 7912e9cfb..b3d62fcd7 100644 --- a/src/ui/dashpay/contact_info_editor.rs +++ b/src/ui/dashpay/contact_info_editor.rs @@ -4,6 +4,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::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; @@ -233,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) { - crate::ui::components::MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } wallet_needs_unlock(wallet) } else { diff --git a/src/ui/dashpay/profile_screen.rs b/src/ui/dashpay/profile_screen.rs index 542237877..45e2970e2 100644 --- a/src/ui/dashpay/profile_screen.rs +++ b/src/ui/dashpay/profile_screen.rs @@ -6,6 +6,7 @@ use crate::model::fee_estimation::format_credits_as_dash; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; use crate::ui::MessageType; +use crate::ui::components::MessageBanner; use crate::ui::components::component_trait::Component; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::identity_selector::IdentitySelector; @@ -865,7 +866,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) { - crate::ui::components::MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } wallet_needs_unlock(wallet) } else { @@ -1328,7 +1329,7 @@ impl ProfileScreen { ui.horizontal(|ui| { if ui.button("Copy URL").clicked() { ui.ctx().copy_text(avatar_url.clone()); - crate::ui::components::MessageBanner::set_global( + MessageBanner::set_global( ui.ctx(), "Avatar URL copied to clipboard", MessageType::Info, diff --git a/src/ui/dashpay/qr_code_generator.rs b/src/ui/dashpay/qr_code_generator.rs index 15bd27a1e..427ea6511 100644 --- a/src/ui/dashpay/qr_code_generator.rs +++ b/src/ui/dashpay/qr_code_generator.rs @@ -3,6 +3,7 @@ use crate::backend_task::dashpay::auto_accept_proof::generate_auto_accept_proof; use crate::context::AppContext; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; +use crate::ui::components::MessageBanner; 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; @@ -88,7 +89,7 @@ impl QRCodeGeneratorScreen { let account_idx = match self.account_index.parse::() { Ok(v) => v, Err(_) => { - crate::ui::components::MessageBanner::set_global( + MessageBanner::set_global( self.app_context.egui_ctx(), "Invalid account index number", MessageType::Error, @@ -100,7 +101,7 @@ impl QRCodeGeneratorScreen { let validity = match self.validity_hours.parse::() { Ok(v) if v > 0 && v <= 720 => v, // Max 30 days _ => { - crate::ui::components::MessageBanner::set_global( + MessageBanner::set_global( self.app_context.egui_ctx(), "Validity hours must be between 1 and 720", MessageType::Error, @@ -113,14 +114,14 @@ impl QRCodeGeneratorScreen { Ok(proof_data) => { let qr_string = proof_data.to_qr_string(); self.generated_qr_data = Some(qr_string); - crate::ui::components::MessageBanner::set_global( + MessageBanner::set_global( self.app_context.egui_ctx(), "QR code generated successfully", MessageType::Success, ); } Err(e) => { - crate::ui::components::MessageBanner::set_global( + MessageBanner::set_global( self.app_context.egui_ctx(), &format!("Failed to generate QR code: {}", e), MessageType::Error, @@ -128,7 +129,7 @@ impl QRCodeGeneratorScreen { } } } else { - crate::ui::components::MessageBanner::set_global( + MessageBanner::set_global( self.app_context.egui_ctx(), "Please select an identity first", MessageType::Error, @@ -264,7 +265,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) { - crate::ui::components::MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } wallet_needs_unlock(wallet) } else { @@ -366,7 +367,7 @@ impl QRCodeGeneratorScreen { } if show_copied_message { - crate::ui::components::MessageBanner::set_global(ui.ctx(), "Copied to clipboard", MessageType::Success); + MessageBanner::set_global(ui.ctx(), "Copied to clipboard", MessageType::Success); } }); diff --git a/src/ui/dashpay/qr_scanner.rs b/src/ui/dashpay/qr_scanner.rs index 5fe6ffe40..d9c742111 100644 --- a/src/ui/dashpay/qr_scanner.rs +++ b/src/ui/dashpay/qr_scanner.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::MessageBanner; use crate::ui::components::dashpay_subscreen_chooser_panel::add_dashpay_subscreen_chooser_panel; use crate::ui::components::identity_selector::IdentitySelector; use crate::ui::components::left_panel::add_left_panel; @@ -50,7 +51,7 @@ impl QRScannerScreen { fn parse_qr_code(&mut self) { if self.qr_data_input.is_empty() { self.parsed_qr_data = None; - crate::ui::components::MessageBanner::set_global( + MessageBanner::set_global( self.app_context.egui_ctx(), "Please enter QR code data", MessageType::Error, @@ -61,7 +62,7 @@ impl QRScannerScreen { match AutoAcceptProofData::from_qr_string(&self.qr_data_input) { Ok(data) => { self.parsed_qr_data = Some(data); - crate::ui::components::MessageBanner::set_global( + MessageBanner::set_global( self.app_context.egui_ctx(), "QR code parsed successfully", MessageType::Success, @@ -69,7 +70,7 @@ impl QRScannerScreen { } Err(e) => { self.parsed_qr_data = None; - crate::ui::components::MessageBanner::set_global( + MessageBanner::set_global( self.app_context.egui_ctx(), &format!("Invalid QR code: {}", e), MessageType::Error, @@ -94,7 +95,7 @@ impl QRScannerScreen { ) { Some(key) => key, None => { - crate::ui::components::MessageBanner::set_global( + MessageBanner::set_global( self.app_context.egui_ctx(), "No suitable signing key found. This operation requires a ECDSA_SECP256K1 AUTHENTICATION key.", MessageType::Error, @@ -120,14 +121,14 @@ impl QRScannerScreen { return AppAction::BackendTask(task); } else { - crate::ui::components::MessageBanner::set_global( + MessageBanner::set_global( self.app_context.egui_ctx(), "Please parse a QR code first", MessageType::Error, ); } } else { - crate::ui::components::MessageBanner::set_global( + MessageBanner::set_global( self.app_context.egui_ctx(), "Please select an identity", MessageType::Error, @@ -256,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) { - crate::ui::components::MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } wallet_needs_unlock(wallet) } else { diff --git a/src/ui/dashpay/send_payment.rs b/src/ui/dashpay/send_payment.rs index 64aff949c..f3c4150a0 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; @@ -86,7 +87,7 @@ impl SendPaymentScreen { fn send_payment(&mut self) -> AppAction { // Validate amount if self.amount.value() == 0 { - crate::ui::components::MessageBanner::set_global( + MessageBanner::set_global( self.app_context.egui_ctx(), "Please enter an amount", MessageType::Error, @@ -111,11 +112,7 @@ impl SendPaymentScreen { }; if let Err(e) = wallet_check { - crate::ui::components::MessageBanner::set_global( - self.app_context.egui_ctx(), - &e, - MessageType::Error, - ); + MessageBanner::set_global(self.app_context.egui_ctx(), &e, MessageType::Error); return AppAction::None; } @@ -123,7 +120,7 @@ impl SendPaymentScreen { let amount_dash = match self.amount.dash_to_duffs() { Ok(duffs) => duffs as f64 / 100_000_000.0, Err(e) => { - crate::ui::components::MessageBanner::set_global( + MessageBanner::set_global( self.app_context.egui_ctx(), &format!("Invalid amount: {}", e), MessageType::Error, @@ -200,7 +197,7 @@ impl SendPaymentScreen { }; if let Some(e) = wallet_open_error { - crate::ui::components::MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } if needs_unlock { @@ -350,7 +347,7 @@ impl SendPaymentScreen { if ui.add_enabled(send_enabled, send_button).clicked() { if self.memo.len() > 100 { - crate::ui::components::MessageBanner::set_global( + MessageBanner::set_global( ui.ctx(), "Memo must be 100 characters or less", MessageType::Error, diff --git a/src/ui/identities/add_existing_identity_screen.rs b/src/ui/identities/add_existing_identity_screen.rs index 31c790a1c..38209a825 100644 --- a/src/ui/identities/add_existing_identity_screen.rs +++ b/src/ui/identities/add_existing_identity_screen.rs @@ -107,7 +107,6 @@ pub struct AddExistingIdentityScreen { 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, @@ -147,7 +146,6 @@ impl AddExistingIdentityScreen { 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(), @@ -620,7 +618,6 @@ impl AddExistingIdentityScreen { }); if wallet_mode_changed { self.add_identity_status = AddIdentityStatus::NotStarted; - self.backend_message = None; self.success_message = None; } ui.add_space(6.0); @@ -672,7 +669,6 @@ impl AddExistingIdentityScreen { if ui.add(button).clicked() { self.add_identity_status = AddIdentityStatus::WaitingForResult; - self.backend_message = None; self.success_message = None; let handle = MessageBanner::set_global( self.app_context.egui_ctx(), @@ -836,7 +832,6 @@ impl AddExistingIdentityScreen { if ui.add_enabled(is_valid, button).clicked() { self.add_identity_status = AddIdentityStatus::WaitingForResult; - self.backend_message = None; self.success_message = None; let handle = MessageBanner::set_global( self.app_context.egui_ctx(), @@ -960,7 +955,6 @@ impl AddExistingIdentityScreen { self.dpns_name_input.clear(); self.show_pop_up_info = None; self.add_identity_status = AddIdentityStatus::NotStarted; - self.backend_message = None; self.success_message = None; return AppAction::None; } @@ -990,10 +984,8 @@ impl ScreenLike for AddExistingIdentityScreen { } self.success_message = Some(message.to_string()); self.add_identity_status = AddIdentityStatus::Complete; - self.backend_message = None; } else { // This is a progress update - update the banner text - self.backend_message = Some(message.to_string()); if let Some(ref handle) = self.refresh_banner { handle.set_message(message); } @@ -1011,7 +1003,6 @@ impl ScreenLike for AddExistingIdentityScreen { } 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 @@ -1021,10 +1012,8 @@ impl ScreenLike for AddExistingIdentityScreen { } self.success_message = Some(msg); self.add_identity_status = AddIdentityStatus::Complete; - self.backend_message = None; } else { // This is a progress update - update the banner text - self.backend_message = Some(msg.clone()); if let Some(ref handle) = self.refresh_banner { handle.set_message(&msg); } @@ -1104,7 +1093,6 @@ impl ScreenLike for AddExistingIdentityScreen { if mode_changed { self.add_identity_status = AddIdentityStatus::NotStarted; - self.backend_message = None; self.success_message = None; } diff --git a/src/ui/network_chooser_screen.rs b/src/ui/network_chooser_screen.rs index 83dcb3890..451a9e510 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,7 +899,6 @@ impl NetworkChooserScreen { let file_name = path.file_name().and_then(|f| f.to_str()); if let Some(file_name) = file_name { self.custom_dash_qt_path = None; - self.custom_dash_qt_error_message = None; // Handle macOS .app bundles let resolved_path = if cfg!(target_os = "macos") @@ -925,7 +922,6 @@ impl NetworkChooserScreen { if is_valid { self.custom_dash_qt_path = Some(resolved_path); - self.custom_dash_qt_error_message = None; self.save().expect("Expected to save db settings"); } else { let required_file_name = if cfg!(target_os = "windows") { @@ -935,49 +931,35 @@ 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, + ); } } } if self.custom_dash_qt_path.is_some() && ui.button("Clear").clicked() { self.custom_dash_qt_path = Some(PathBuf::new()); - self.custom_dash_qt_error_message = None; self.save().expect("Expected to save db settings"); } }); - if let Some(ref file) = self.custom_dash_qt_path { - if !file.as_os_str().is_empty() { - ui.horizontal(|ui| { - ui.label("Path:"); - ui.label( - egui::RichText::new(format_path_for_display(file)) - .color(DashColors::SUCCESS) - .italics(), - ); - }); - } - } else 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; - } - }); - }); + if let Some(ref file) = self.custom_dash_qt_path + && !file.as_os_str().is_empty() + { + ui.horizontal(|ui| { + ui.label("Path:"); + ui.label( + egui::RichText::new(format_path_for_display(file)) + .color(DashColors::SUCCESS) + .italics(), + ); + }); } // Configuration Options diff --git a/src/ui/tokens/burn_tokens_screen.rs b/src/ui/tokens/burn_tokens_screen.rs index ccff95524..88a0d53c9 100644 --- a/src/ui/tokens/burn_tokens_screen.rs +++ b/src/ui/tokens/burn_tokens_screen.rs @@ -284,12 +284,12 @@ impl BurnTokensScreen { 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, }, ) @@ -300,6 +300,18 @@ impl BurnTokensScreen { }) }; + let signing_key = match self.selected_key.clone() { + Some(key) => key, + None => { + MessageBanner::set_global( + self.app_context.egui_ctx(), + "No signing key selected", + MessageType::Error, + ); + return AppAction::None; + } + }; + // Dispatch the actual backend burn action AppAction::BackendTasks( vec![ @@ -307,7 +319,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 { diff --git a/src/ui/tokens/claim_tokens_screen.rs b/src/ui/tokens/claim_tokens_screen.rs index d1bca872a..0bf7c8c07 100644 --- a/src/ui/tokens/claim_tokens_screen.rs +++ b/src/ui/tokens/claim_tokens_screen.rs @@ -216,6 +216,18 @@ impl ClaimTokensScreen { handle.with_elapsed(); self.refresh_banner = Some(handle); + let signing_key = match self.selected_key.clone() { + Some(key) => key, + None => { + MessageBanner::set_global( + ui.ctx(), + "No signing key selected", + MessageType::Error, + ); + return AppAction::None; + } + }; + AppAction::BackendTasks( vec![ BackendTask::TokenTask(Box::new(TokenTask::ClaimTokens { @@ -223,7 +235,7 @@ impl ClaimTokensScreen { token_position: self.identity_token_basic_info.token_position, actor_identity: self.identity.clone(), distribution_type, - signing_key: self.selected_key.clone().expect("No key selected"), + signing_key, public_note: self.public_note.clone(), })), BackendTask::TokenTask(Box::new(TokenTask::QueryMyTokenBalances)), diff --git a/src/ui/tokens/destroy_frozen_funds_screen.rs b/src/ui/tokens/destroy_frozen_funds_screen.rs index 3484f40c4..e0ddb1866 100644 --- a/src/ui/tokens/destroy_frozen_funds_screen.rs +++ b/src/ui/tokens/destroy_frozen_funds_screen.rs @@ -294,12 +294,12 @@ impl DestroyFrozenFundsScreen { 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, }, ) @@ -310,12 +310,24 @@ impl DestroyFrozenFundsScreen { }) }; + let signing_key = match self.selected_key.clone() { + Some(key) => key, + None => { + MessageBanner::set_global( + self.app_context.egui_ctx(), + "No signing key selected", + MessageType::Error, + ); + return AppAction::None; + } + }; + AppAction::BackendTask(BackendTask::TokenTask(Box::new( TokenTask::DestroyFrozenFunds { actor_identity: self.identity.clone(), data_contract, token_position: self.identity_token_info.token_position, - signing_key: self.selected_key.clone().expect("No key selected"), + signing_key, public_note: if self.group_action_id.is_some() { None } else { diff --git a/src/ui/tokens/freeze_tokens_screen.rs b/src/ui/tokens/freeze_tokens_screen.rs index 90b1485ad..da500db06 100644 --- a/src/ui/tokens/freeze_tokens_screen.rs +++ b/src/ui/tokens/freeze_tokens_screen.rs @@ -286,12 +286,12 @@ impl FreezeTokensScreen { // 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, }, ) @@ -302,12 +302,24 @@ impl FreezeTokensScreen { }) }; + let signing_key = match self.selected_key.clone() { + Some(key) => key, + None => { + MessageBanner::set_global( + self.app_context.egui_ctx(), + "No signing key selected", + MessageType::Error, + ); + return AppAction::None; + } + }; + // Dispatch to backend AppAction::BackendTask(BackendTask::TokenTask(Box::new(TokenTask::FreezeTokens { actor_identity: self.identity.clone(), data_contract, token_position: self.identity_token_info.token_position, - signing_key: self.selected_key.clone().expect("No key selected"), + signing_key, public_note: if self.group_action_id.is_some() { None } else { diff --git a/src/ui/tokens/mint_tokens_screen.rs b/src/ui/tokens/mint_tokens_screen.rs index 1456752dd..cf72c9e60 100644 --- a/src/ui/tokens/mint_tokens_screen.rs +++ b/src/ui/tokens/mint_tokens_screen.rs @@ -322,12 +322,12 @@ impl MintTokensScreen { 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, }, ) @@ -338,11 +338,23 @@ impl MintTokensScreen { }) }; + let signing_key = match self.selected_key.clone() { + Some(key) => key, + None => { + MessageBanner::set_global( + self.app_context.egui_ctx(), + "No signing key selected", + MessageType::Error, + ); + return AppAction::None; + } + }; + AppAction::BackendTask(BackendTask::TokenTask(Box::new(TokenTask::MintTokens { sending_identity: self.identity_token_info.identity.clone(), data_contract, token_position: self.identity_token_info.token_position, - signing_key: self.selected_key.clone().expect("No key selected"), + signing_key, public_note: if self.group_action_id.is_some() { None } else { diff --git a/src/ui/tokens/pause_tokens_screen.rs b/src/ui/tokens/pause_tokens_screen.rs index bb736a4bb..a129e14ca 100644 --- a/src/ui/tokens/pause_tokens_screen.rs +++ b/src/ui/tokens/pause_tokens_screen.rs @@ -225,12 +225,12 @@ impl PauseTokensScreen { 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, }, ) @@ -241,11 +241,23 @@ impl PauseTokensScreen { }) }; + let signing_key = match self.selected_key.clone() { + Some(key) => key, + None => { + MessageBanner::set_global( + self.app_context.egui_ctx(), + "No signing key selected", + MessageType::Error, + ); + return AppAction::None; + } + }; + AppAction::BackendTask(BackendTask::TokenTask(Box::new(TokenTask::PauseTokens { actor_identity: self.identity.clone(), data_contract, token_position: self.identity_token_info.token_position, - signing_key: self.selected_key.clone().expect("No key selected"), + signing_key, public_note: if self.group_action_id.is_some() { None } else { diff --git a/src/ui/tokens/resume_tokens_screen.rs b/src/ui/tokens/resume_tokens_screen.rs index 32824ef65..150168906 100644 --- a/src/ui/tokens/resume_tokens_screen.rs +++ b/src/ui/tokens/resume_tokens_screen.rs @@ -225,12 +225,12 @@ impl ResumeTokensScreen { 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, }, ) @@ -241,11 +241,23 @@ impl ResumeTokensScreen { }) }; + let signing_key = match self.selected_key.clone() { + Some(key) => key, + None => { + MessageBanner::set_global( + self.app_context.egui_ctx(), + "No signing key selected", + MessageType::Error, + ); + return AppAction::None; + } + }; + AppAction::BackendTask(BackendTask::TokenTask(Box::new(TokenTask::ResumeTokens { actor_identity: self.identity.clone(), data_contract, token_position: self.identity_token_info.token_position, - signing_key: self.selected_key.clone().expect("No key selected"), + signing_key, public_note: if self.group_action_id.is_some() { None } else { diff --git a/src/ui/tokens/set_token_price_screen.rs b/src/ui/tokens/set_token_price_screen.rs index 08eb71b17..0c967f27d 100644 --- a/src/ui/tokens/set_token_price_screen.rs +++ b/src/ui/tokens/set_token_price_screen.rs @@ -739,12 +739,12 @@ impl SetTokenPriceScreen { 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,13 +755,25 @@ impl SetTokenPriceScreen { }) }; + let signing_key = match self.selected_key.clone() { + Some(key) => key, + None => { + MessageBanner::set_global( + self.app_context.egui_ctx(), + "No signing key selected", + MessageType::Error, + ); + return AppAction::None; + } + }; + // Create and return the backend task AppAction::BackendTask(BackendTask::TokenTask(Box::new( TokenTask::SetDirectPurchasePrice { 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 { 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/transfer_tokens_screen.rs b/src/ui/tokens/transfer_tokens_screen.rs index 428c889d8..ef1a62987 100644 --- a/src/ui/tokens/transfer_tokens_screen.rs +++ b/src/ui/tokens/transfer_tokens_screen.rs @@ -243,6 +243,18 @@ impl TransferTokensScreen { .expect("Data contract not found"), ); + let signing_key = match self.selected_key.clone() { + Some(key) => key, + None => { + MessageBanner::set_global( + self.app_context.egui_ctx(), + "No signing key selected", + MessageType::Error, + ); + return AppAction::None; + } + }; + AppAction::BackendTask(BackendTask::TokenTask(Box::new( TokenTask::TransferTokens { sending_identity: self.identity.clone(), @@ -250,7 +262,7 @@ impl TransferTokensScreen { amount: self.amount.clone().unwrap_or(Amount::new(0, 0)).value(), data_contract, token_position: self.identity_token_balance.token_position, - signing_key: self.selected_key.clone().expect("No key selected"), + signing_key, public_note: self.public_note.clone(), }, ))) diff --git a/src/ui/tokens/unfreeze_tokens_screen.rs b/src/ui/tokens/unfreeze_tokens_screen.rs index 6dec8e34c..9f4499401 100644 --- a/src/ui/tokens/unfreeze_tokens_screen.rs +++ b/src/ui/tokens/unfreeze_tokens_screen.rs @@ -288,12 +288,12 @@ impl UnfreezeTokensScreen { // 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, }, ) @@ -304,13 +304,25 @@ impl UnfreezeTokensScreen { }) }; + let signing_key = match self.selected_key.clone() { + Some(key) => key, + None => { + MessageBanner::set_global( + self.app_context.egui_ctx(), + "No signing key selected", + MessageType::Error, + ); + return AppAction::None; + } + }; + // Dispatch to backend AppAction::BackendTask(BackendTask::TokenTask(Box::new( TokenTask::UnfreezeTokens { actor_identity: self.identity.clone(), data_contract, token_position: self.identity_token_info.token_position, - signing_key: self.selected_key.clone().expect("No key selected"), + signing_key, public_note: if self.group_action_id.is_some() { None } else { diff --git a/src/ui/tokens/update_token_config.rs b/src/ui/tokens/update_token_config.rs index a31725a97..afba7cb48 100644 --- a/src/ui/tokens/update_token_config.rs +++ b/src/ui/tokens/update_token_config.rs @@ -750,12 +750,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, }, ) @@ -766,20 +766,28 @@ 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) = self.signing_key.clone() { + 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, + public_note: if self.group_action_id.is_some() { + None + } else { + self.public_note.clone() + }, + group_info, }, - group_info, - }, - ))); + ))); + } else { + MessageBanner::set_global( + ui.ctx(), + "No signing key selected", + MessageType::Error, + ); + } } } From 7daf5d2a6d939d51d73b8b230d1d4b2398f8f088 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 25 Feb 2026 18:00:53 +0100 Subject: [PATCH 19/46] fix(ui): address review findings SEC-08, SEC-09, RUST-015, SEC-05, SEC-07 - SEC-08: Restore safe if-let-Some pattern in WithdrawalScreen::refresh() to prevent double unwrap() panic on DB error or deleted identity - SEC-09: Restore original DB lookup in SendPaymentScreen::load_contact_info() replacing hardcoded "alice.dash" mock data - RUST-015: Revert unimplemented!() back to ui.label() in update_token_config MarketplaceTradeMode arm - SEC-05: Add success banners for contact request accept/reject in ContactRequests::display_task_result - SEC-07: Add MessageBanner::clear_all_global() and call it from AppState::change_network() to prevent stale banners leaking across network switches Co-Authored-By: Claude Opus 4.6 --- src/app.rs | 4 ++++ src/ui/components/message_banner.rs | 8 ++++++++ src/ui/dashpay/contact_requests.rs | 11 +++++++++++ src/ui/dashpay/send_payment.rs | 21 ++++++++++++++++++--- src/ui/identities/withdraw_screen.rs | 10 ++++++---- src/ui/tokens/update_token_config.rs | 2 +- 6 files changed, 48 insertions(+), 8 deletions(-) diff --git a/src/app.rs b/src/app.rs index 31435b76a..c354bf56c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -866,6 +866,10 @@ impl AppState { self.chosen_network = network; let app_context = self.current_app_context().clone(); + // Clear stale banners from the previous network context so error/success + // messages don't leak across network boundaries. + MessageBanner::clear_all_global(app_context.egui_ctx()); + for screen in self.main_screens.values_mut() { screen.change_context(app_context.clone()) } diff --git a/src/ui/components/message_banner.rs b/src/ui/components/message_banner.rs index 2dc09e61a..ae17bca94 100644 --- a/src/ui/components/message_banner.rs +++ b/src/ui/components/message_banner.rs @@ -361,6 +361,14 @@ impl MessageBanner { 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 { diff --git a/src/ui/dashpay/contact_requests.rs b/src/ui/dashpay/contact_requests.rs index f15eb9c36..77dc1f224 100644 --- a/src/ui/dashpay/contact_requests.rs +++ b/src/ui/dashpay/contact_requests.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::MessageBanner; use crate::ui::components::component_trait::Component; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::identity_selector::IdentitySelector; @@ -1184,10 +1185,20 @@ impl ScreenLike for ContactRequests { BackendTaskSuccessResult::DashPayContactRequestAccepted(request_id) => { // Mark as accepted only after successful backend operation self.accepted_requests.insert(request_id); + 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); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Contact request rejected", + MessageType::Success, + ); } BackendTaskSuccessResult::DashPayContactAlreadyEstablished(_) => { // Message display is handled globally by AppState diff --git a/src/ui/dashpay/send_payment.rs b/src/ui/dashpay/send_payment.rs index f3c4150a0..05cce6d26 100644 --- a/src/ui/dashpay/send_payment.rs +++ b/src/ui/dashpay/send_payment.rs @@ -79,9 +79,24 @@ impl SendPaymentScreen { } fn load_contact_info(&mut self) { - // TODO: Load contact info from backend/database - // Mock data for now - self.to_contact_name = Some("alice.dash".to_string()); + let owner_id = self.from_identity.identity.id(); + let network_str = self.app_context.network.to_string(); + if let Ok(contacts) = self + .app_context + .db + .load_dashpay_contacts(&owner_id, &network_str) + { + let contact_bytes = self.to_contact_id.to_buffer().to_vec(); + if let Some(contact) = contacts + .iter() + .find(|c| c.contact_identity_id == contact_bytes) + { + self.to_contact_name = contact + .username + .clone() + .or_else(|| contact.display_name.clone()); + } + } } fn send_payment(&mut self) -> AppAction { diff --git a/src/ui/identities/withdraw_screen.rs b/src/ui/identities/withdraw_screen.rs index ab40c3901..b143e7605 100644 --- a/src/ui/identities/withdraw_screen.rs +++ b/src/ui/identities/withdraw_screen.rs @@ -352,14 +352,16 @@ impl ScreenLike for WithdrawalScreen { fn refresh(&mut self) { // Refresh the identity because there might be new keys - self.identity = self + if let Some(refreshed) = self .app_context .load_local_qualified_identities() - .unwrap() + .unwrap_or_default() .into_iter() .find(|identity| identity.identity.id() == self.identity.identity.id()) - .unwrap(); - self.max_amount = self.identity.identity.balance(); + { + self.identity = refreshed; + self.max_amount = self.identity.identity.balance(); + } } /// Renders the UI components for the withdrawal screen diff --git a/src/ui/tokens/update_token_config.rs b/src/ui/tokens/update_token_config.rs index afba7cb48..a693b79f5 100644 --- a/src/ui/tokens/update_token_config.rs +++ b/src/ui/tokens/update_token_config.rs @@ -675,7 +675,7 @@ impl UpdateTokenConfigScreen { ui.label("No parameters to edit for this entry."); } TokenConfigurationChangeItem::MarketplaceTradeMode(_) => { - unimplemented!("marketplace settings not implemented yet") + ui.label("Marketplace settings are not yet supported."); } } }); From 377c4ea3fe90f90dc9c96d0dd2542cea87fedff6 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Feb 2026 10:31:35 +0100 Subject: [PATCH 20/46] docs: update coding conventions and message display guidance Add fallible constructor rule (Result when they can fail), rename section to "General rules", and document MessageBanner idempotency (no guard needed for set_global). Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3fe2179bb..43fe0dafd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,13 +58,11 @@ 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`. +* Constructors SHOULD return `Result` when they can fail. +* Handle errors using `MessageBanner`; see **Message Display** section below. ## Architecture Overview @@ -174,7 +172,7 @@ 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. ## Database From af33bd64cf845c721d8d494491313a260eb2a8aa Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Feb 2026 13:03:54 +0100 Subject: [PATCH 21/46] fix(ui): replace expect/panic with graceful error handling (SEC-10) Replace all expect() calls in token screen constructors and confirmation handlers with MessageBanner error display. Constructors handle errors internally and return Self with degraded state, keeping create_screen() clean. refresh() methods now show errors via MessageBanner instead of tracing-only logging. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 3 +- .../group_actions_screen.rs | 1 - src/ui/identities/withdraw_screen.rs | 9 ++- src/ui/tokens/claim_tokens_screen.rs | 11 ++- src/ui/tokens/destroy_frozen_funds_screen.rs | 9 ++- src/ui/tokens/direct_token_purchase_screen.rs | 14 +++- src/ui/tokens/freeze_tokens_screen.rs | 9 ++- src/ui/tokens/mint_tokens_screen.rs | 9 ++- src/ui/tokens/tokens_screen/my_tokens.rs | 28 ++------ src/ui/tokens/transfer_tokens_screen.rs | 70 ++++++++++++++----- src/ui/tokens/unfreeze_tokens_screen.rs | 9 ++- 11 files changed, 119 insertions(+), 53 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 43fe0dafd..cf009e9ce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,8 +61,7 @@ scripts/safe-cargo.sh +nightly fmt --all ### General rules * When a method takes `&AppContext` (or `Option<&AppContext>`), place it as the first parameter after `self`. -* Constructors SHOULD return `Result` when they can fail. -* Handle errors using `MessageBanner`; see **Message Display** section below. +* Screen constructors handle errors internally via `MessageBanner` and return `Self` with degraded state. Keep `create_screen()` clean — no error handling at callsites. ## Architecture Overview diff --git a/src/ui/contracts_documents/group_actions_screen.rs b/src/ui/contracts_documents/group_actions_screen.rs index 67bcb235d..48218e424 100644 --- a/src/ui/contracts_documents/group_actions_screen.rs +++ b/src/ui/contracts_documents/group_actions_screen.rs @@ -364,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, diff --git a/src/ui/identities/withdraw_screen.rs b/src/ui/identities/withdraw_screen.rs index b143e7605..0efe36fa9 100644 --- a/src/ui/identities/withdraw_screen.rs +++ b/src/ui/identities/withdraw_screen.rs @@ -355,7 +355,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()) { diff --git a/src/ui/tokens/claim_tokens_screen.rs b/src/ui/tokens/claim_tokens_screen.rs index 0bf7c8c07..f26f648fa 100644 --- a/src/ui/tokens/claim_tokens_screen.rs +++ b/src/ui/tokens/claim_tokens_screen.rs @@ -78,10 +78,17 @@ impl ClaimTokensScreen { ) -> Self { let identity = app_context .load_local_qualified_identities() - .unwrap_or_default() + .unwrap_or_else(|e| { + MessageBanner::set_global( + app_context.egui_ctx(), + &format!("Failed to load identities: {e}"), + MessageType::Error, + ); + vec![] + }) .into_iter() .find(|id| id.identity.id() == identity_token_basic_info.identity_id) - .expect("No local qualified identity found for this token’s identity."); + .expect("Identity must exist in local store after successful navigation"); let identity_clone = identity.identity.clone(); let mut possible_key = identity_clone.get_first_public_key_matching( diff --git a/src/ui/tokens/destroy_frozen_funds_screen.rs b/src/ui/tokens/destroy_frozen_funds_screen.rs index e0ddb1866..1ceae203e 100644 --- a/src/ui/tokens/destroy_frozen_funds_screen.rs +++ b/src/ui/tokens/destroy_frozen_funds_screen.rs @@ -201,7 +201,14 @@ impl DestroyFrozenFundsScreen { let all_identities = app_context .load_local_qualified_identities() - .expect("Identities not loaded"); + .unwrap_or_else(|e| { + MessageBanner::set_global( + app_context.egui_ctx(), + &format!("Failed to load identities: {e}"), + MessageType::Error, + ); + vec![] + }); Self { identity: identity_token_info.identity.clone(), diff --git a/src/ui/tokens/direct_token_purchase_screen.rs b/src/ui/tokens/direct_token_purchase_screen.rs index e55dcc194..721d8f901 100644 --- a/src/ui/tokens/direct_token_purchase_screen.rs +++ b/src/ui/tokens/direct_token_purchase_screen.rs @@ -297,6 +297,18 @@ impl PurchaseTokenScreen { match dialog.show(ui).inner.dialog_response { Some(ConfirmationStatus::Confirmed) => { + let signing_key = match self.selected_key.clone() { + Some(key) => key, + None => { + MessageBanner::set_global( + self.app_context.egui_ctx(), + "No signing key selected", + MessageType::Error, + ); + return AppAction::None; + } + }; + self.confirmation_dialog = None; self.status = PurchaseTokensStatus::WaitingForResult; let handle = MessageBanner::set_global( @@ -315,7 +327,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, })), diff --git a/src/ui/tokens/freeze_tokens_screen.rs b/src/ui/tokens/freeze_tokens_screen.rs index da500db06..8b138a08a 100644 --- a/src/ui/tokens/freeze_tokens_screen.rs +++ b/src/ui/tokens/freeze_tokens_screen.rs @@ -85,7 +85,14 @@ 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"); + .unwrap_or_else(|e| { + MessageBanner::set_global( + app_context.egui_ctx(), + &format!("Failed to load identities: {e}"), + MessageType::Error, + ); + vec![] + }); let possible_key = identity_token_info .identity diff --git a/src/ui/tokens/mint_tokens_screen.rs b/src/ui/tokens/mint_tokens_screen.rs index cf72c9e60..bc37b1231 100644 --- a/src/ui/tokens/mint_tokens_screen.rs +++ b/src/ui/tokens/mint_tokens_screen.rs @@ -87,7 +87,14 @@ 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"); + .unwrap_or_else(|e| { + MessageBanner::set_global( + app_context.egui_ctx(), + &format!("Failed to load identities: {e}"), + MessageType::Error, + ); + vec![] + }); let possible_key = identity_token_info .identity diff --git a/src/ui/tokens/tokens_screen/my_tokens.rs b/src/ui/tokens/tokens_screen/my_tokens.rs index ce1ad4ed5..c7d07d085 100644 --- a/src/ui/tokens/tokens_screen/my_tokens.rs +++ b/src/ui/tokens/tokens_screen/my_tokens.rs @@ -693,12 +693,7 @@ 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) => { @@ -736,12 +731,7 @@ 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) => { @@ -757,12 +747,7 @@ 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) => { @@ -778,12 +763,7 @@ 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) => { diff --git a/src/ui/tokens/transfer_tokens_screen.rs b/src/ui/tokens/transfer_tokens_screen.rs index ef1a62987..0f5f6e0ab 100644 --- a/src/ui/tokens/transfer_tokens_screen.rs +++ b/src/ui/tokens/transfer_tokens_screen.rs @@ -74,12 +74,19 @@ impl TransferTokensScreen { ) -> Self { let known_identities = app_context .load_local_qualified_identities() - .expect("Identities not loaded"); + .unwrap_or_else(|e| { + MessageBanner::set_global( + app_context.egui_ctx(), + &format!("Failed to load identities: {e}"), + MessageType::Error, + ); + vec![] + }); let identity = known_identities .iter() .find(|identity| identity.identity.id() == identity_token_balance.identity_id) - .expect("Identity not found") + .expect("Identity must exist in local store after successful navigation") .clone(); let max_amount = Amount::from(&identity_token_balance); let identity_clone = identity.identity.clone(); @@ -236,12 +243,20 @@ impl TransferTokensScreen { handle.with_elapsed(); self.refresh_banner = Some(handle); - 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 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 signing_key = match self.selected_key.clone() { Some(key) => key, @@ -301,23 +316,42 @@ impl ScreenLike for TransferTokensScreen { fn refresh(&mut self) { // Refresh the identity because there might be new keys - self.identity = self + if let Some(refreshed) = self .app_context .load_local_qualified_identities() - .unwrap() + .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()) - .unwrap(); - let token_balances = self + { + self.identity = refreshed; + } + match 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(); + { + Ok(token_balances) => { + self.max_amount = token_balances + .values() + .find(|balance| balance.identity_id == self.identity.identity.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 diff --git a/src/ui/tokens/unfreeze_tokens_screen.rs b/src/ui/tokens/unfreeze_tokens_screen.rs index 9f4499401..3fe8e0e70 100644 --- a/src/ui/tokens/unfreeze_tokens_screen.rs +++ b/src/ui/tokens/unfreeze_tokens_screen.rs @@ -90,7 +90,14 @@ impl UnfreezeTokensScreen { // TODO: filter to include only frozen identities let frozen_identities = app_context .load_local_qualified_identities() - .expect("Identities not loaded"); + .unwrap_or_else(|e| { + MessageBanner::set_global( + app_context.egui_ctx(), + &format!("Failed to load identities: {e}"), + MessageType::Error, + ); + vec![] + }); let possible_key = identity_token_info .identity From ddf0edad1f2850b6e47465823fbb5b165dbf21c1 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Feb 2026 13:16:16 +0100 Subject: [PATCH 22/46] refactor(ui): accept impl Display/Debug in MessageBanner API Change MessageBanner public methods to accept `impl Display` for message text and `impl Debug` for details, instead of `&str`. Remove needless `&format!(...)` borrows across 27 call sites. Co-Authored-By: Claude Opus 4.6 --- src/ui/components/message_banner.rs | 48 +++++++++++-------- .../contracts_documents_screen.rs | 2 +- .../group_actions_screen.rs | 2 +- src/ui/dashpay/contact_profile_viewer.rs | 2 +- src/ui/dashpay/contacts_list.rs | 2 +- src/ui/dashpay/qr_code_generator.rs | 2 +- src/ui/dashpay/qr_scanner.rs | 2 +- src/ui/dashpay/send_payment.rs | 2 +- src/ui/dpns/dpns_contested_names_screen.rs | 4 +- src/ui/identities/identities_screen.rs | 2 +- src/ui/identities/keys/add_key_screen.rs | 6 +-- src/ui/identities/keys/key_info_screen.rs | 6 +-- src/ui/identities/withdraw_screen.rs | 2 +- src/ui/network_chooser_screen.rs | 2 +- src/ui/tokens/claim_tokens_screen.rs | 2 +- src/ui/tokens/destroy_frozen_funds_screen.rs | 2 +- src/ui/tokens/freeze_tokens_screen.rs | 2 +- src/ui/tokens/mint_tokens_screen.rs | 2 +- .../tokens/tokens_screen/contract_details.rs | 2 +- src/ui/tokens/tokens_screen/mod.rs | 4 +- src/ui/tokens/tokens_screen/my_tokens.rs | 2 +- src/ui/tokens/tokens_screen/token_creator.rs | 2 +- src/ui/tokens/transfer_tokens_screen.rs | 6 +-- src/ui/tokens/unfreeze_tokens_screen.rs | 2 +- src/ui/tools/grovestark_screen.rs | 4 +- src/ui/wallets/asset_lock_detail_screen.rs | 2 +- src/ui/wallets/single_key_send_screen.rs | 2 +- src/ui/wallets/wallets_screen/mod.rs | 10 ++-- 28 files changed, 68 insertions(+), 60 deletions(-) diff --git a/src/ui/components/message_banner.rs b/src/ui/components/message_banner.rs index ae17bca94..bfa6bb49e 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}; @@ -148,7 +149,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(); @@ -183,13 +184,14 @@ impl BannerHandle { /// Attach optional technical details to this banner. /// Details are shown in a collapsible section (collapsed by default). /// 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 +199,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 +239,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,7 +293,12 @@ 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) { return BannerHandle { @@ -303,7 +308,7 @@ 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); } @@ -321,12 +326,14 @@ impl MessageBanner { /// 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,13 +343,13 @@ 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); + b.reset_to(new_text, message_type); } else if let Some(existing) = banners.iter_mut().find(|b| b.text == new_text) { key = existing.key; - existing.reset_to(new_text.to_string(), message_type); + existing.reset_to(new_text, 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); } @@ -355,7 +362,8 @@ 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); diff --git a/src/ui/contracts_documents/contracts_documents_screen.rs b/src/ui/contracts_documents/contracts_documents_screen.rs index bc5c6c759..92d3a260d 100644 --- a/src/ui/contracts_documents/contracts_documents_screen.rs +++ b/src/ui/contracts_documents/contracts_documents_screen.rs @@ -216,7 +216,7 @@ impl DocumentQueryScreen { self.document_query_status = DocumentQueryStatus::Error; MessageBanner::set_global( ui.ctx(), - &format!("Failed to parse query properly: {}", e), + format!("Failed to parse query properly: {}", e), MessageType::Error, ); } diff --git a/src/ui/contracts_documents/group_actions_screen.rs b/src/ui/contracts_documents/group_actions_screen.rs index 48218e424..2aa7d4637 100644 --- a/src/ui/contracts_documents/group_actions_screen.rs +++ b/src/ui/contracts_documents/group_actions_screen.rs @@ -276,7 +276,7 @@ impl GroupActionsScreen { FetchGroupActionsStatus::Error; MessageBanner::set_global( ui.ctx(), - &format!("Failed to get identity token info: {}", e), + format!("Failed to get identity token info: {}", e), MessageType::Error, ); return; diff --git a/src/ui/dashpay/contact_profile_viewer.rs b/src/ui/dashpay/contact_profile_viewer.rs index b3dbe10ca..b61b6ef64 100644 --- a/src/ui/dashpay/contact_profile_viewer.rs +++ b/src/ui/dashpay/contact_profile_viewer.rs @@ -534,7 +534,7 @@ impl ContactProfileViewerScreen { Err(e) => { crate::ui::components::MessageBanner::set_global( ui.ctx(), - &format!("Failed to save: {}", e), + format!("Failed to save: {}", e), MessageType::Error, ); } diff --git a/src/ui/dashpay/contacts_list.rs b/src/ui/dashpay/contacts_list.rs index 421575902..a6405ce60 100644 --- a/src/ui/dashpay/contacts_list.rs +++ b/src/ui/dashpay/contacts_list.rs @@ -914,7 +914,7 @@ impl ContactsList { ); MessageBanner::set_global( ui.ctx(), - &format!("Failed to update contact: {}", e), + format!("Failed to update contact: {}", e), MessageType::Error, ); } else { diff --git a/src/ui/dashpay/qr_code_generator.rs b/src/ui/dashpay/qr_code_generator.rs index 427ea6511..155b9738b 100644 --- a/src/ui/dashpay/qr_code_generator.rs +++ b/src/ui/dashpay/qr_code_generator.rs @@ -123,7 +123,7 @@ impl QRCodeGeneratorScreen { Err(e) => { MessageBanner::set_global( self.app_context.egui_ctx(), - &format!("Failed to generate QR code: {}", e), + format!("Failed to generate QR code: {}", e), MessageType::Error, ); } diff --git a/src/ui/dashpay/qr_scanner.rs b/src/ui/dashpay/qr_scanner.rs index d9c742111..db456632a 100644 --- a/src/ui/dashpay/qr_scanner.rs +++ b/src/ui/dashpay/qr_scanner.rs @@ -72,7 +72,7 @@ impl QRScannerScreen { self.parsed_qr_data = None; MessageBanner::set_global( self.app_context.egui_ctx(), - &format!("Invalid QR code: {}", e), + format!("Invalid QR code: {}", e), MessageType::Error, ); } diff --git a/src/ui/dashpay/send_payment.rs b/src/ui/dashpay/send_payment.rs index 05cce6d26..5791f373b 100644 --- a/src/ui/dashpay/send_payment.rs +++ b/src/ui/dashpay/send_payment.rs @@ -137,7 +137,7 @@ impl SendPaymentScreen { Err(e) => { MessageBanner::set_global( self.app_context.egui_ctx(), - &format!("Invalid amount: {}", e), + format!("Invalid amount: {}", e), MessageType::Error, ); return AppAction::None; diff --git a/src/ui/dpns/dpns_contested_names_screen.rs b/src/ui/dpns/dpns_contested_names_screen.rs index 26d68d743..8d590e589 100644 --- a/src/ui/dpns/dpns_contested_names_screen.rs +++ b/src/ui/dpns/dpns_contested_names_screen.rs @@ -963,13 +963,13 @@ impl DPNSScreen { { MessageBanner::set_global( ui.ctx(), - &format!("Failed to set alias: {}", e), + format!("Failed to set alias: {}", e), MessageType::Error, ); } else { MessageBanner::set_global( ui.ctx(), - &format!( + format!( "Alias set to '{}' for identity {}", alias_with_suffix, identifier.to_string(Encoding::Base58) diff --git a/src/ui/identities/identities_screen.rs b/src/ui/identities/identities_screen.rs index fa4501aa3..ab9040e82 100644 --- a/src/ui/identities/identities_screen.rs +++ b/src/ui/identities/identities_screen.rs @@ -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, ); } diff --git a/src/ui/identities/keys/add_key_screen.rs b/src/ui/identities/keys/add_key_screen.rs index 0053d83b0..733defd6d 100644 --- a/src/ui/identities/keys/add_key_screen.rs +++ b/src/ui/identities/keys/add_key_screen.rs @@ -187,7 +187,7 @@ impl AddKeyScreen { self.add_key_status = AddKeyStatus::Error; MessageBanner::set_global( self.app_context.egui_ctx(), - &format!("Issue verifying private key: {}", err), + format!("Issue verifying private key: {}", err), MessageType::Error, ); } else { @@ -210,7 +210,7 @@ impl AddKeyScreen { self.add_key_status = AddKeyStatus::Error; MessageBanner::set_global( self.app_context.egui_ctx(), - &format!("Invalid contract ID: {}", e), + format!("Invalid contract ID: {}", e), MessageType::Error, ); return app_action; @@ -238,7 +238,7 @@ impl AddKeyScreen { self.add_key_status = AddKeyStatus::Error; MessageBanner::set_global( self.app_context.egui_ctx(), - &format!("Issue verifying private key: {}", err), + format!("Issue verifying private key: {}", err), MessageType::Error, ); } else if validation_result.unwrap() { diff --git a/src/ui/identities/keys/key_info_screen.rs b/src/ui/identities/keys/key_info_screen.rs index 0f0d8aaf5..0a742ca81 100644 --- a/src/ui/identities/keys/key_info_screen.rs +++ b/src/ui/identities/keys/key_info_screen.rs @@ -634,7 +634,7 @@ impl KeyInfoScreen { if let Err(err) = validation_result { MessageBanner::set_global( self.app_context.egui_ctx(), - &format!("Issue verifying private key {}", err), + format!("Issue verifying private key {}", err), MessageType::Error, ); } else if validation_result.unwrap() { @@ -650,7 +650,7 @@ impl KeyInfoScreen { { MessageBanner::set_global( self.app_context.egui_ctx(), - &format!("Issue saving: {}", e), + format!("Issue saving: {}", e), MessageType::Error, ); } @@ -800,7 +800,7 @@ impl KeyInfoScreen { { MessageBanner::set_global( ui.ctx(), - &format!("Issue saving: {}", e), + format!("Issue saving: {}", e), MessageType::Error, ); } diff --git a/src/ui/identities/withdraw_screen.rs b/src/ui/identities/withdraw_screen.rs index 0efe36fa9..74ff5472a 100644 --- a/src/ui/identities/withdraw_screen.rs +++ b/src/ui/identities/withdraw_screen.rs @@ -358,7 +358,7 @@ impl ScreenLike for WithdrawalScreen { .unwrap_or_else(|e| { MessageBanner::set_global( self.app_context.egui_ctx(), - &format!("Failed to load local identities: {e}"), + format!("Failed to load local identities: {e}"), MessageType::Error, ); vec![] diff --git a/src/ui/network_chooser_screen.rs b/src/ui/network_chooser_screen.rs index 451a9e510..1a650ac97 100644 --- a/src/ui/network_chooser_screen.rs +++ b/src/ui/network_chooser_screen.rs @@ -933,7 +933,7 @@ impl NetworkChooserScreen { }; MessageBanner::set_global( ui.ctx(), - &format!( + format!( "Invalid file: Please select a valid '{}'.", required_file_name ), diff --git a/src/ui/tokens/claim_tokens_screen.rs b/src/ui/tokens/claim_tokens_screen.rs index f26f648fa..a3126c6c7 100644 --- a/src/ui/tokens/claim_tokens_screen.rs +++ b/src/ui/tokens/claim_tokens_screen.rs @@ -81,7 +81,7 @@ impl ClaimTokensScreen { .unwrap_or_else(|e| { MessageBanner::set_global( app_context.egui_ctx(), - &format!("Failed to load identities: {e}"), + format!("Failed to load identities: {e}"), MessageType::Error, ); vec![] diff --git a/src/ui/tokens/destroy_frozen_funds_screen.rs b/src/ui/tokens/destroy_frozen_funds_screen.rs index 1ceae203e..9972861ad 100644 --- a/src/ui/tokens/destroy_frozen_funds_screen.rs +++ b/src/ui/tokens/destroy_frozen_funds_screen.rs @@ -204,7 +204,7 @@ impl DestroyFrozenFundsScreen { .unwrap_or_else(|e| { MessageBanner::set_global( app_context.egui_ctx(), - &format!("Failed to load identities: {e}"), + format!("Failed to load identities: {e}"), MessageType::Error, ); vec![] diff --git a/src/ui/tokens/freeze_tokens_screen.rs b/src/ui/tokens/freeze_tokens_screen.rs index 8b138a08a..17c3715f3 100644 --- a/src/ui/tokens/freeze_tokens_screen.rs +++ b/src/ui/tokens/freeze_tokens_screen.rs @@ -88,7 +88,7 @@ impl FreezeTokensScreen { .unwrap_or_else(|e| { MessageBanner::set_global( app_context.egui_ctx(), - &format!("Failed to load identities: {e}"), + format!("Failed to load identities: {e}"), MessageType::Error, ); vec![] diff --git a/src/ui/tokens/mint_tokens_screen.rs b/src/ui/tokens/mint_tokens_screen.rs index bc37b1231..96773a0a0 100644 --- a/src/ui/tokens/mint_tokens_screen.rs +++ b/src/ui/tokens/mint_tokens_screen.rs @@ -90,7 +90,7 @@ impl MintTokensScreen { .unwrap_or_else(|e| { MessageBanner::set_global( app_context.egui_ctx(), - &format!("Failed to load identities: {e}"), + format!("Failed to load identities: {e}"), MessageType::Error, ); vec![] diff --git a/src/ui/tokens/tokens_screen/contract_details.rs b/src/ui/tokens/tokens_screen/contract_details.rs index 56cfb7029..f315e87d0 100644 --- a/src/ui/tokens/tokens_screen/contract_details.rs +++ b/src/ui/tokens/tokens_screen/contract_details.rs @@ -100,7 +100,7 @@ impl TokensScreen { Err(e) => { MessageBanner::set_global( ui.ctx(), - &e.to_string(), + e.to_string(), MessageType::Error, ); } diff --git a/src/ui/tokens/tokens_screen/mod.rs b/src/ui/tokens/tokens_screen/mod.rs index 103e9e367..a5fca535b 100644 --- a/src/ui/tokens/tokens_screen/mod.rs +++ b/src/ui/tokens/tokens_screen/mod.rs @@ -2626,7 +2626,7 @@ impl TokensScreen { { MessageBanner::set_global( self.app_context.egui_ctx(), - &format!("Error removing token balance: {}", e), + format!("Error removing token balance: {}", e), MessageType::Error, ); } else { @@ -2688,7 +2688,7 @@ impl TokensScreen { { MessageBanner::set_global( self.app_context.egui_ctx(), - &format!("Error removing token balance: {}", e), + format!("Error removing token balance: {}", e), MessageType::Error, ); } else { diff --git a/src/ui/tokens/tokens_screen/my_tokens.rs b/src/ui/tokens/tokens_screen/my_tokens.rs index c7d07d085..71fefeeb3 100644 --- a/src/ui/tokens/tokens_screen/my_tokens.rs +++ b/src/ui/tokens/tokens_screen/my_tokens.rs @@ -679,7 +679,7 @@ impl TokensScreen { Err(e) => { MessageBanner::set_global( ui.ctx(), - &format!("Error fetching token contract: {e}"), + format!("Error fetching token contract: {e}"), MessageType::Error, ); } diff --git a/src/ui/tokens/tokens_screen/token_creator.rs b/src/ui/tokens/tokens_screen/token_creator.rs index 2bcc17b41..b39f04ba1 100644 --- a/src/ui/tokens/tokens_screen/token_creator.rs +++ b/src/ui/tokens/tokens_screen/token_creator.rs @@ -921,7 +921,7 @@ impl TokensScreen { ) { Ok(dc) => dc, Err(e) => { - MessageBanner::set_global(context, &format!("Error building contract V1: {e}"), MessageType::Error); + MessageBanner::set_global(context, format!("Error building contract V1: {e}"), MessageType::Error); return; } }; diff --git a/src/ui/tokens/transfer_tokens_screen.rs b/src/ui/tokens/transfer_tokens_screen.rs index 0f5f6e0ab..dd6c46f31 100644 --- a/src/ui/tokens/transfer_tokens_screen.rs +++ b/src/ui/tokens/transfer_tokens_screen.rs @@ -77,7 +77,7 @@ impl TransferTokensScreen { .unwrap_or_else(|e| { MessageBanner::set_global( app_context.egui_ctx(), - &format!("Failed to load identities: {e}"), + format!("Failed to load identities: {e}"), MessageType::Error, ); vec![] @@ -322,7 +322,7 @@ impl ScreenLike for TransferTokensScreen { .unwrap_or_else(|e| { MessageBanner::set_global( self.app_context.egui_ctx(), - &format!("Failed to load local identities: {e}"), + format!("Failed to load local identities: {e}"), MessageType::Error, ); vec![] @@ -347,7 +347,7 @@ impl ScreenLike for TransferTokensScreen { Err(e) => { MessageBanner::set_global( self.app_context.egui_ctx(), - &format!("Failed to load token balances: {e}"), + format!("Failed to load token balances: {e}"), MessageType::Error, ); } diff --git a/src/ui/tokens/unfreeze_tokens_screen.rs b/src/ui/tokens/unfreeze_tokens_screen.rs index 3fe8e0e70..5168cdb9b 100644 --- a/src/ui/tokens/unfreeze_tokens_screen.rs +++ b/src/ui/tokens/unfreeze_tokens_screen.rs @@ -93,7 +93,7 @@ impl UnfreezeTokensScreen { .unwrap_or_else(|e| { MessageBanner::set_global( app_context.egui_ctx(), - &format!("Failed to load identities: {e}"), + format!("Failed to load identities: {e}"), MessageType::Error, ); vec![] diff --git a/src/ui/tools/grovestark_screen.rs b/src/ui/tools/grovestark_screen.rs index c62007e57..4917651bd 100644 --- a/src/ui/tools/grovestark_screen.rs +++ b/src/ui/tools/grovestark_screen.rs @@ -400,7 +400,7 @@ impl GroveSTARKScreen { Err(e) => { MessageBanner::set_global( app_context.egui_ctx(), - &format!("Failed to get private key: {}", e), + format!("Failed to get private key: {}", e), MessageType::Error, ); self.is_generating = false; @@ -476,7 +476,7 @@ impl GroveSTARKScreen { Err(e) => { MessageBanner::set_global( app_context.egui_ctx(), - &format!("Failed to parse proof: {}", e), + format!("Failed to parse proof: {}", e), MessageType::Error, ); self.is_verifying = false; diff --git a/src/ui/wallets/asset_lock_detail_screen.rs b/src/ui/wallets/asset_lock_detail_screen.rs index 7847d17ef..5f7f943d9 100644 --- a/src/ui/wallets/asset_lock_detail_screen.rs +++ b/src/ui/wallets/asset_lock_detail_screen.rs @@ -231,7 +231,7 @@ impl AssetLockDetailScreen { self.show_private_key_popup = true; } Err(e) => { - MessageBanner::set_global(ui.ctx(), &format!("Error retrieving private key: {}", e), MessageType::Error); + MessageBanner::set_global(ui.ctx(), format!("Error retrieving private key: {}", e), MessageType::Error); } } } diff --git a/src/ui/wallets/single_key_send_screen.rs b/src/ui/wallets/single_key_send_screen.rs index c053db104..50e203e62 100644 --- a/src/ui/wallets/single_key_send_screen.rs +++ b/src/ui/wallets/single_key_send_screen.rs @@ -784,7 +784,7 @@ impl SingleKeyWalletSendScreen { Err(e) => { MessageBanner::set_global( ui.ctx(), - &format!("Failed to unlock: {}", e), + format!("Failed to unlock: {}", e), MessageType::Error, ); } diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index 83fad32e7..3887aa657 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -571,7 +571,7 @@ impl WalletsBalancesScreen { { MessageBanner::set_global( ui.ctx(), - &format!("Failed to remove: {}", e), + format!("Failed to remove: {}", e), MessageType::Error, ); } else { @@ -737,14 +737,14 @@ impl WalletsBalancesScreen { MessageBanner::set_global( self.app_context.egui_ctx(), - &format!("Removed wallet \"{}\" successfully", alias), + format!("Removed wallet \"{}\" successfully", alias), MessageType::Success, ); } Err(err) => { MessageBanner::set_global( self.app_context.egui_ctx(), - &format!("Failed to remove wallet: {}", err), + format!("Failed to remove wallet: {}", err), MessageType::Error, ); } @@ -1441,7 +1441,7 @@ impl WalletsBalancesScreen { Err(err) => { MessageBanner::set_global( self.app_context.egui_ctx(), - &format!("Failed to lock wallet: {}", err), + format!("Failed to lock wallet: {}", err), MessageType::Error, ); return; @@ -1966,7 +1966,7 @@ impl ScreenLike for WalletsBalancesScreen { if let Some(warn_msg) = warning { MessageBanner::set_global( self.app_context.egui_ctx(), - &format!("Wallet refreshed with warning: {}", warn_msg), + format!("Wallet refreshed with warning: {}", warn_msg), MessageType::Info, ); } else { From b0ce74b7f2845be1911ddab42bfb61172327ca50 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Feb 2026 16:21:16 +0100 Subject: [PATCH 23/46] fix(ui): remove error Failed to get best chain lock for mainnet, testnet, devnet, and local Fixes https://github.com/dashpay/dash-evo-tool/issues/633 --- src/backend_task/core/mod.rs | 14 +------------- src/context/connection_status.rs | 14 +++----------- 2 files changed, 4 insertions(+), 24 deletions(-) diff --git a/src/backend_task/core/mod.rs b/src/backend_task/core/mod.rs index 3795b9dfd..4b4a78ffd 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 e7e685247..78b2e1996 100644 --- a/src/context/connection_status.rs +++ b/src/context/connection_status.rs @@ -295,9 +295,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, @@ -320,16 +321,7 @@ impl ConnectionStatus { } } _ => {} - }, - TaskResult::Error(message) => { - if message.contains( - "Failed to get best chain lock for mainnet, testnet, devnet, and local", - ) { - self.set_rpc_online(false); - self.refresh_state(); - } } - _ => {} } } From ff310583aa9d507f0a9f1d5391f2fc3774ac1823 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 27 Feb 2026 10:25:06 +0100 Subject: [PATCH 24/46] feat(ui): add automatic connection status banners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Display persistent MessageBanner notifications based on network connection state transitions. Mode-aware messages guide users toward the right recovery action (RPC vs SPV). - Disconnected (RPC): "Disconnected — check that Dash Core is running" - Disconnected (SPV): "Disconnected — check your internet connection" - Syncing (RPC): "Syncing with Dash Core…" - Syncing (SPV): "SPV sync in progress…" - Synced: banner cleared Uses Option for change detection, with None as initial/post-network-switch sentinel to force first evaluation. Closes #667 (partial — action links deferred to follow-up) Co-Authored-By: Claude Opus 4.6 --- src/app.rs | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/src/app.rs b/src/app.rs index c354bf56c..70f732e39 100644 --- a/src/app.rs +++ b/src/app.rs @@ -6,12 +6,13 @@ use crate::backend_task::core::CoreItem; 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::{ @@ -91,6 +92,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)] @@ -705,6 +711,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) @@ -876,6 +884,51 @@ impl AppState { 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, Syncing, and Synced. + fn update_connection_banner(&mut self, ctx: &egui::Context, app_context: &Arc) { + let current_state = app_context.connection_status().overall_state(); + if self.previous_connection_state == Some(current_state) { + return; + } + // Clear old banner if present + if let Some(handle) = self.connection_banner_handle.take() { + handle.clear(); + } + // Display new banner based on current state + let connection_status = app_context.connection_status(); + 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::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 { @@ -1159,6 +1212,8 @@ impl App for AppState { .trigger_refresh(active_context.as_ref()), ); + self.update_connection_banner(ctx, &active_context); + for action in actions { match action { AppAction::None => {} From d3250109b2de59f4c94637b9fd899856bb795347 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 27 Feb 2026 11:08:24 +0100 Subject: [PATCH 25/46] fix(ui): pass TaskError directly to with_details() to avoid double-formatting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous code used `format!("{err:?}")` which produced a String, then `with_details()` applied `{:#?}` again — wrapping the output in quotes and escaping inner characters. Passing `&err` directly lets Debug format once. Co-Authored-By: Claude Opus 4.6 --- src/app.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app.rs b/src/app.rs index 63e4e799d..1477a5dfc 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1044,7 +1044,9 @@ 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:?}")); + // NOTE: TaskError Debug output is visible to users in developer + // mode. Ensure inner error types don't expose secrets (see #667). + handle.with_details(&err); } self.visible_screen_mut() .display_message(&msg, MessageType::Error); From d8a5604e1933f2664c86df07d32f42f9e2955034 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 27 Feb 2026 12:47:26 +0100 Subject: [PATCH 26/46] fix(ui): correct copy-paste error messages in token screens Replace "Burning" error messages that were copy-pasted from burn screen into freeze, destroy, and resume token screens with contextually correct messages. Co-Authored-By: Claude Opus 4.6 --- src/ui/tokens/destroy_frozen_funds_screen.rs | 6 +++--- src/ui/tokens/freeze_tokens_screen.rs | 6 +++--- src/ui/tokens/resume_tokens_screen.rs | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/ui/tokens/destroy_frozen_funds_screen.rs b/src/ui/tokens/destroy_frozen_funds_screen.rs index 9972861ad..5232e1a0f 100644 --- a/src/ui/tokens/destroy_frozen_funds_screen.rs +++ b/src/ui/tokens/destroy_frozen_funds_screen.rs @@ -115,7 +115,7 @@ impl DestroyFrozenFundsScreen { .authorized_to_make_change_action_takers() { AuthorizedActionTakers::NoOne => { - set_error_banner("Burning is not allowed on this token"); + set_error_banner("Destroying frozen funds is not allowed on this token"); None } AuthorizedActionTakers::ContractOwner => { @@ -123,14 +123,14 @@ impl DestroyFrozenFundsScreen { != identity_token_info.identity.identity.id() { set_error_banner( - "You are not allowed to burn this token. Only the contract owner is.", + "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() { - set_error_banner("You are not allowed to burn this token"); + set_error_banner("You are not allowed to destroy frozen funds on this token"); } None } diff --git a/src/ui/tokens/freeze_tokens_screen.rs b/src/ui/tokens/freeze_tokens_screen.rs index 17c3715f3..08ce929e8 100644 --- a/src/ui/tokens/freeze_tokens_screen.rs +++ b/src/ui/tokens/freeze_tokens_screen.rs @@ -115,7 +115,7 @@ impl FreezeTokensScreen { .authorized_to_make_change_action_takers() { AuthorizedActionTakers::NoOne => { - set_error_banner("Burning is not allowed on this token"); + set_error_banner("Freezing is not allowed on this token"); None } AuthorizedActionTakers::ContractOwner => { @@ -123,14 +123,14 @@ impl FreezeTokensScreen { != identity_token_info.identity.identity.id() { set_error_banner( - "You are not allowed to burn this token. Only the contract owner is.", + "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() { - set_error_banner("You are not allowed to burn this token"); + set_error_banner("You are not allowed to freeze this token"); } None } diff --git a/src/ui/tokens/resume_tokens_screen.rs b/src/ui/tokens/resume_tokens_screen.rs index 150168906..1a4d585b7 100644 --- a/src/ui/tokens/resume_tokens_screen.rs +++ b/src/ui/tokens/resume_tokens_screen.rs @@ -97,7 +97,7 @@ impl ResumeTokensScreen { .authorized_to_make_change_action_takers() { AuthorizedActionTakers::NoOne => { - set_error_banner("Burning is not allowed on this token"); + set_error_banner("Resuming is not allowed on this token"); None } AuthorizedActionTakers::ContractOwner => { @@ -105,14 +105,14 @@ impl ResumeTokensScreen { != identity_token_info.identity.identity.id() { set_error_banner( - "You are not allowed to burn this token. Only the contract owner is.", + "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() { - set_error_banner("You are not allowed to burn this token"); + set_error_banner("You are not allowed to resume this token"); } None } From 538af9963ccf37b5bc870be2c62500d194f5e748 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 27 Feb 2026 12:47:41 +0100 Subject: [PATCH 27/46] fix(ui): restore lost success/error messages in 5 screens Replace display_message() calls with MessageBanner::set_global() in screens where display_message() is now a side-effect-only handler and no longer displays messages directly. Affected screens: create_asset_lock_screen, wallets_screen (MineBlocks), address_table (export error), profile_screen (validation), contact_details. Co-Authored-By: Claude Opus 4.6 --- src/ui/dashpay/contact_details.rs | 7 ++++++- src/ui/dashpay/profile_screen.rs | 13 +++++++++++-- src/ui/wallets/create_asset_lock_screen.rs | 13 +++++++++---- src/ui/wallets/wallets_screen/address_table.rs | 11 +++++++++-- src/ui/wallets/wallets_screen/mod.rs | 7 ++++++- 5 files changed, 41 insertions(+), 10 deletions(-) diff --git a/src/ui/dashpay/contact_details.rs b/src/ui/dashpay/contact_details.rs index 7ba9ad0ec..32394c310 100644 --- a/src/ui/dashpay/contact_details.rs +++ b/src/ui/dashpay/contact_details.rs @@ -6,6 +6,7 @@ use crate::model::qualified_identity::QualifiedIdentity; 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; +use crate::ui::components::message_banner::MessageBanner; use crate::ui::components::styled::island_central_panel; use crate::ui::components::top_panel::add_top_panel; use crate::ui::dashpay::DashPaySubscreen; @@ -637,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/profile_screen.rs b/src/ui/dashpay/profile_screen.rs index 45e2970e2..bc99b8978 100644 --- a/src/ui/dashpay/profile_screen.rs +++ b/src/ui/dashpay/profile_screen.rs @@ -11,6 +11,7 @@ use crate::ui::components::component_trait::Component; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::identity_selector::IdentitySelector; use crate::ui::components::info_popup::InfoPopup; +use crate::ui::components::message_banner::MessageBanner; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; @@ -394,7 +395,11 @@ impl ProfileScreen { 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; } @@ -432,7 +437,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 } } diff --git a/src/ui/wallets/create_asset_lock_screen.rs b/src/ui/wallets/create_asset_lock_screen.rs index f554b081c..6c45c9451 100644 --- a/src/ui/wallets/create_asset_lock_screen.rs +++ b/src/ui/wallets/create_asset_lock_screen.rs @@ -10,6 +10,7 @@ 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; +use crate::ui::components::message_banner::MessageBanner; use crate::ui::components::styled::island_central_panel; use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; @@ -720,7 +721,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, ); @@ -735,7 +737,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, ); @@ -757,7 +760,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, ); @@ -772,7 +776,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/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/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index 3887aa657..da81c818d 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -15,6 +15,7 @@ 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; +use crate::ui::components::message_banner::MessageBanner; use crate::ui::components::styled::island_central_panel; use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{WalletUnlockPopup, WalletUnlockResult}; @@ -2092,7 +2093,11 @@ impl ScreenLike for WalletsBalancesScreen { } 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, + ); } _ => {} } From 660743d743de698f1c099144a7564763ef39a9e4 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 27 Feb 2026 12:48:14 +0100 Subject: [PATCH 28/46] fix(ui): replace unwrap/expect with graceful error handling Replace double unwrap in transfer_screen refresh() with unwrap_or_else + MessageBanner error display, matching the pattern from withdraw_screen. SEC-002 tokens_screen skipped: the .expect() calls are only on compile-time embedded image data (include_bytes!) which is safe. Co-Authored-By: Claude Opus 4.6 --- src/ui/identities/transfer_screen.rs | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/ui/identities/transfer_screen.rs b/src/ui/identities/transfer_screen.rs index e1b4fc7cb..c366cf40a 100644 --- a/src/ui/identities/transfer_screen.rs +++ b/src/ui/identities/transfer_screen.rs @@ -524,14 +524,24 @@ impl ScreenLike for TransferScreen { fn refresh(&mut self) { // Refresh the identity because there might be new keys - self.identity = self + let identities = self .app_context .load_local_qualified_identities() - .unwrap() - .into_iter() + .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()) - .unwrap(); - self.max_amount = self.identity.identity.balance(); + { + self.identity = refreshed.clone(); + self.max_amount = self.identity.identity.balance(); + } } /// Renders the UI components for the withdrawal screen From 09cd4b0c730b07290da5fa98a273a2536b11d593 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 27 Feb 2026 12:48:54 +0100 Subject: [PATCH 29/46] refactor(ui): migrate masternode_list_diff_screen to global MessageBanner Replace ~15 local ui_state.message assignments and custom render_message_banner() with MessageBanner::set_global() via the display_message() trait method. Remove the message field from UiState and the unused Color32 import. Co-Authored-By: Claude Opus 4.6 --- src/ui/tools/masternode_list_diff_screen.rs | 99 +++++---------------- 1 file changed, 24 insertions(+), 75 deletions(-) diff --git a/src/ui/tools/masternode_list_diff_screen.rs b/src/ui/tools/masternode_list_diff_screen.rs index 16a2dfb2f..10438906a 100644 --- a/src/ui/tools/masternode_list_diff_screen.rs +++ b/src/ui/tools/masternode_list_diff_screen.rs @@ -9,6 +9,7 @@ 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::components::MessageBanner; use crate::ui::{MessageType, RootScreenType, ScreenLike}; use dash_sdk::dashcore_rpc::RpcApi; use dash_sdk::dashcore_rpc::json::QuorumType; @@ -40,7 +41,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 +70,6 @@ struct InputState { struct UiState { selected_tab: usize, show_popup_for_render_masternode_list_engine: bool, - message: Option<(String, MessageType)>, error: Option, } @@ -1163,7 +1163,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 +1206,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(); @@ -1535,8 +1533,7 @@ impl MasternodeListDiffScreen { .clicked() { self.clear(); - self.ui_state.message = - Some(("Cleared all data".to_string(), MessageType::Success)); + self.display_message("Cleared all data", MessageType::Success); } if ui .button("Clear keep base") @@ -1546,10 +1543,10 @@ impl MasternodeListDiffScreen { .clicked() { self.clear_keep_base(); - self.ui_state.message = Some(( - "Cleared data and kept base diff".to_string(), + self.display_message( + "Cleared data and kept base diff", MessageType::Success, - )); + ); } }); // Add bottom padding so the horizontal scrollbar doesn't overlap buttons @@ -1558,38 +1555,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; @@ -4182,15 +4147,11 @@ impl MasternodeListDiffScreen { impl ScreenLike for MasternodeListDiffScreen { 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.task.pending = None; - // Preserve local error state so the screen's inline error rendering works - self.ui_state.error = Some(message.to_string()); - } - MessageType::Success | MessageType::Info => {} + if matches!(message_type, MessageType::Error | MessageType::Warning) { + self.task.pending = None; + self.ui_state.error = Some(message.to_string()); } + MessageBanner::set_global(self.app_context.egui_ctx(), message, message_type); } fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { @@ -4242,10 +4203,10 @@ impl ScreenLike for MasternodeListDiffScreen { if matches!(self.task.pending, Some(PendingTask::DmlDiffNoRotation)) { if let Some(task) = self.build_validation_diffs_task() { self.task.queued_task = Some(task); - self.ui_state.message = Some(( - "Fetched DMLs (no rotation); fetching validation diffs…".to_string(), + self.display_message( + "Fetched DMLs (no rotation); fetching validation diffs…", MessageType::Info, - )); + ); } else if !self.data.masternode_list_engine.masternode_lists.is_empty() { // Fallback: attempt verification directly if let Err(e) = self @@ -4259,21 +4220,14 @@ impl ScreenLike for MasternodeListDiffScreen { self.ui_state.error = Some(e.to_string()); } self.task.pending = None; - self.ui_state.message = Some(( - "Fetched DMLs (no rotation)".to_string(), - MessageType::Success, - )); + self.display_message("Fetched DMLs (no rotation)", MessageType::Success); } else { self.task.pending = None; - self.ui_state.message = Some(( - "Fetched DMLs (no rotation)".to_string(), - MessageType::Success, - )); + self.display_message("Fetched DMLs (no rotation)", MessageType::Success); } } else { self.task.pending = None; - self.ui_state.message = - Some(("Fetched DML diff".to_string(), MessageType::Success)); + self.display_message("Fetched DML diff", MessageType::Success); } self.selection.selected_dml_diff_key = None; self.selection.selected_quorum_in_diff_index = None; @@ -4331,14 +4285,13 @@ impl ScreenLike for MasternodeListDiffScreen { // Queue extra diffs required for verification (previous behavior) if let Some(task) = self.build_validation_diffs_task() { self.task.queued_task = Some(task); - self.ui_state.message = Some(( - "Fetched QR info + DMLs; fetching validation diffs…".to_string(), + self.display_message( + "Fetched QR info + DMLs; fetching validation diffs…", MessageType::Info, - )); + ); } else { self.task.pending = None; - self.ui_state.message = - Some(("Fetched QR info + DMLs".to_string(), MessageType::Success)); + self.display_message("Fetched QR info + DMLs", MessageType::Success); } } BackendTaskSuccessResult::MnListFetchedDiffs { items } => { @@ -4386,10 +4339,10 @@ impl ScreenLike for MasternodeListDiffScreen { self.ui_state.error = Some(e.to_string()); } self.task.pending = None; - self.ui_state.message = Some(( - "Fetched validation diffs and verified non-rotating quorums".to_string(), + self.display_message( + "Fetched validation diffs and verified non-rotating quorums", MessageType::Success, - )); + ); } BackendTaskSuccessResult::MnListChainLockSigs { entries } => { for ((h, bh), sig) in entries { @@ -4403,10 +4356,7 @@ impl ScreenLike for MasternodeListDiffScreen { } } self.task.pending = None; - self.ui_state.message = Some(( - "Fetched chain lock signatures".to_string(), - MessageType::Success, - )); + self.display_message("Fetched chain lock signatures", MessageType::Success); } _ => {} } @@ -4442,7 +4392,6 @@ impl ScreenLike for MasternodeListDiffScreen { inner |= AppAction::BackendTask(task); } - self.render_message_banner(ui); self.render_error_banner(ui); self.render_pending_status(ui); From 88c175622e0c5227645f5f0982f80f2b5bbbf1eb Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 27 Feb 2026 12:50:05 +0100 Subject: [PATCH 30/46] fix(ui): improve banner eviction logging and atomics - Upgrade BANNER_KEY_COUNTER from Relaxed to SeqCst ordering for future-proofing against multi-threaded usage - Log evicted banners at warn level in set_global() and replace_global() - Add comment explaining why show_global() always writes back Co-Authored-By: Claude Opus 4.6 --- src/ui/components/message_banner.rs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/ui/components/message_banner.rs b/src/ui/components/message_banner.rs index bfa6bb49e..5ac4ea443 100644 --- a/src/ui/components/message_banner.rs +++ b/src/ui/components/message_banner.rs @@ -17,7 +17,9 @@ const DETAILS_MAX_HEIGHT: f32 = 120.0; static BANNER_KEY_COUNTER: AtomicU64 = AtomicU64::new(0); fn next_banner_key() -> u64 { - BANNER_KEY_COUNTER.fetch_add(1, Ordering::Relaxed) + // SeqCst ensures total ordering of key generation, future-proofing + // against potential multi-threaded banner usage. + BANNER_KEY_COUNTER.fetch_add(1, Ordering::SeqCst) } /// The domain type for `MessageBanner`, representing the banner's lifecycle state. @@ -310,7 +312,11 @@ impl MessageBanner { if !text.is_empty() { 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, evicted.text + ); } set_banners(ctx, banners); } @@ -351,7 +357,11 @@ impl MessageBanner { key = next_banner_key(); 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, evicted.text + ); } } set_banners(ctx, banners); @@ -390,6 +400,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); } From b9fa9142f9af85aaf44ede073124680658e3c6a1 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 27 Feb 2026 12:50:11 +0100 Subject: [PATCH 31/46] chore: remove resolved TODO.md All items tracked in the unified message display TODO have been addressed or moved to the review findings. Co-Authored-By: Claude Opus 4.6 --- docs/ai-design/2026-02-17-unified-messages/TODO.md | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 docs/ai-design/2026-02-17-unified-messages/TODO.md diff --git a/docs/ai-design/2026-02-17-unified-messages/TODO.md b/docs/ai-design/2026-02-17-unified-messages/TODO.md deleted file mode 100644 index fa854b19e..000000000 --- a/docs/ai-design/2026-02-17-unified-messages/TODO.md +++ /dev/null @@ -1,13 +0,0 @@ -# Unified messages TODO - -1. fn display_message right now in many cases only sets some screen-scoped field to indicate error state: - ``` - fn display_message(&mut self, _message: &str, message_type: MessageType) { - // Banner display is handled globally by AppState; this is only for side-effects. - if let MessageType::Error = message_type { - self.transfer_tokens_status = TransferTokensStatus::Error; - } - } - ``` - - Check if we have any good way to unify that. It doesn't sound like something that should be in display_message - rather on_message() or message_handler() From 55e9be8ed55e46e18636b2d4e6ea1c2ee86e15ca Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 27 Feb 2026 12:51:46 +0100 Subject: [PATCH 32/46] docs(ui): add INTENTIONAL markers and API documentation - Document why with_details() accepts Debug (not Display): structured error context is more useful in diagnostic details panes - Document replace_global() fallback-to-add behavior as intentional - Add INTENTIONAL(SEC-003) marker for developer mode error details - Add INTENTIONAL(SEC-004) marker for BannerHandle Send+Sync safety Co-Authored-By: Claude Opus 4.6 --- src/app.rs | 6 ++++-- src/ui/components/message_banner.rs | 15 ++++++++++++++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/app.rs b/src/app.rs index 1477a5dfc..7eec49044 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1044,8 +1044,10 @@ 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() { - // NOTE: TaskError Debug output is visible to users in developer - // mode. Ensure inner error types don't expose secrets (see #667). + // 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() diff --git a/src/ui/components/message_banner.rs b/src/ui/components/message_banner.rs index 5ac4ea443..807e08009 100644 --- a/src/ui/components/message_banner.rs +++ b/src/ui/components/message_banner.rs @@ -132,6 +132,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, @@ -185,6 +189,12 @@ 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: impl fmt::Debug) -> Option<&Self> { let details = format!("{:#?}", details); @@ -327,7 +337,10 @@ 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. /// /// Returns a [`BannerHandle`] for updating or clearing the banner later. pub fn replace_global( From 753a8896d1488c3bf27e7a8ee9a66d64c5cf97c5 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 27 Feb 2026 13:02:36 +0100 Subject: [PATCH 33/46] refactor(ui): extract shared token validation and fix ordering - Add validate_signing_key() helper in tokens/mod.rs to eliminate duplicated signing key validation across 12 token screens - Move signing key validation BEFORE WaitingForResult state transition so users see immediate errors instead of loading spinner then error - Replace is_err()/unwrap() anti-pattern with idiomatic let-else blocks in freeze, mint, transfer, destroy_frozen_funds, unfreeze screens Co-Authored-By: Claude Opus 4.6 --- src/ui/tokens/burn_tokens_screen.rs | 20 ++++----- src/ui/tokens/claim_tokens_screen.rs | 20 ++++----- src/ui/tokens/destroy_frozen_funds_screen.rs | 26 ++++-------- src/ui/tokens/direct_token_purchase_screen.rs | 14 ++----- src/ui/tokens/freeze_tokens_screen.rs | 26 ++++-------- src/ui/tokens/mint_tokens_screen.rs | 27 ++++-------- src/ui/tokens/mod.rs | 26 ++++++++++++ src/ui/tokens/pause_tokens_screen.rs | 20 ++++----- src/ui/tokens/resume_tokens_screen.rs | 20 ++++----- src/ui/tokens/set_token_price_screen.rs | 18 +++----- src/ui/tokens/transfer_tokens_screen.rs | 41 ++++++++----------- src/ui/tokens/unfreeze_tokens_screen.rs | 26 ++++-------- src/ui/tokens/update_token_config.rs | 11 ++--- 13 files changed, 124 insertions(+), 171 deletions(-) diff --git a/src/ui/tokens/burn_tokens_screen.rs b/src/ui/tokens/burn_tokens_screen.rs index 88a0d53c9..685659d57 100644 --- a/src/ui/tokens/burn_tokens_screen.rs +++ b/src/ui/tokens/burn_tokens_screen.rs @@ -10,6 +10,7 @@ use crate::ui::components::{BannerHandle, Component, ComponentResponse}; 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; @@ -271,6 +272,13 @@ impl BurnTokensScreen { match dialog.show(ui).inner.dialog_response { Some(ConfirmationStatus::Confirmed) => { self.confirmation_dialog = None; + + // Validate signing key before transitioning to waiting state + let Some(signing_key) = validate_signing_key(&self.app_context, &self.selected_key) + else { + return AppAction::None; + }; + self.status = BurnTokensStatus::WaitingForResult; let handle = MessageBanner::set_global( self.app_context.egui_ctx(), @@ -300,18 +308,6 @@ impl BurnTokensScreen { }) }; - let signing_key = match self.selected_key.clone() { - Some(key) => key, - None => { - MessageBanner::set_global( - self.app_context.egui_ctx(), - "No signing key selected", - MessageType::Error, - ); - return AppAction::None; - } - }; - // Dispatch the actual backend burn action AppAction::BackendTasks( vec![ diff --git a/src/ui/tokens/claim_tokens_screen.rs b/src/ui/tokens/claim_tokens_screen.rs index a3126c6c7..2dc494ad0 100644 --- a/src/ui/tokens/claim_tokens_screen.rs +++ b/src/ui/tokens/claim_tokens_screen.rs @@ -36,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; @@ -214,6 +215,13 @@ impl ClaimTokensScreen { match dialog.show(ui).inner.dialog_response { Some(ConfirmationStatus::Confirmed) => { self.confirmation_dialog = None; + + // Validate signing key before transitioning to waiting state + let Some(signing_key) = validate_signing_key(&self.app_context, &self.selected_key) + else { + return AppAction::None; + }; + self.status = ClaimTokensStatus::WaitingForResult; let handle = MessageBanner::set_global( self.app_context.egui_ctx(), @@ -223,18 +231,6 @@ impl ClaimTokensScreen { handle.with_elapsed(); self.refresh_banner = Some(handle); - let signing_key = match self.selected_key.clone() { - Some(key) => key, - None => { - MessageBanner::set_global( - ui.ctx(), - "No signing key selected", - MessageType::Error, - ); - return AppAction::None; - } - }; - AppAction::BackendTasks( vec![ BackendTask::TokenTask(Box::new(TokenTask::ClaimTokens { diff --git a/src/ui/tokens/destroy_frozen_funds_screen.rs b/src/ui/tokens/destroy_frozen_funds_screen.rs index 5232e1a0f..78ce708c4 100644 --- a/src/ui/tokens/destroy_frozen_funds_screen.rs +++ b/src/ui/tokens/destroy_frozen_funds_screen.rs @@ -22,6 +22,7 @@ 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; @@ -272,14 +273,13 @@ impl DestroyFrozenFundsScreen { } fn confirmation_ok(&mut self) -> AppAction { - let maybe_frozen_id = 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, ], - ); - if maybe_frozen_id.is_err() { + ) else { self.status = DestroyFrozenFundsStatus::Error; MessageBanner::set_global( self.app_context.egui_ctx(), @@ -287,8 +287,12 @@ impl DestroyFrozenFundsScreen { MessageType::Error, ); return AppAction::None; - } - let frozen_id = maybe_frozen_id.unwrap(); + }; + + // Validate signing key before transitioning to waiting state + let Some(signing_key) = validate_signing_key(&self.app_context, &self.selected_key) else { + return AppAction::None; + }; self.status = DestroyFrozenFundsStatus::WaitingForResult; let handle = MessageBanner::set_global( @@ -317,18 +321,6 @@ impl DestroyFrozenFundsScreen { }) }; - let signing_key = match self.selected_key.clone() { - Some(key) => key, - None => { - MessageBanner::set_global( - self.app_context.egui_ctx(), - "No signing key selected", - MessageType::Error, - ); - return AppAction::None; - } - }; - AppAction::BackendTask(BackendTask::TokenTask(Box::new( TokenTask::DestroyFrozenFunds { actor_identity: self.identity.clone(), diff --git a/src/ui/tokens/direct_token_purchase_screen.rs b/src/ui/tokens/direct_token_purchase_screen.rs index 721d8f901..72ddbec04 100644 --- a/src/ui/tokens/direct_token_purchase_screen.rs +++ b/src/ui/tokens/direct_token_purchase_screen.rs @@ -33,6 +33,7 @@ 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}; @@ -297,16 +298,9 @@ impl PurchaseTokenScreen { match dialog.show(ui).inner.dialog_response { Some(ConfirmationStatus::Confirmed) => { - let signing_key = match self.selected_key.clone() { - Some(key) => key, - None => { - MessageBanner::set_global( - self.app_context.egui_ctx(), - "No signing key selected", - MessageType::Error, - ); - return AppAction::None; - } + let Some(signing_key) = validate_signing_key(&self.app_context, &self.selected_key) + else { + return AppAction::None; }; self.confirmation_dialog = None; diff --git a/src/ui/tokens/freeze_tokens_screen.rs b/src/ui/tokens/freeze_tokens_screen.rs index 08ce929e8..0098bc629 100644 --- a/src/ui/tokens/freeze_tokens_screen.rs +++ b/src/ui/tokens/freeze_tokens_screen.rs @@ -22,6 +22,7 @@ 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; @@ -263,14 +264,13 @@ impl FreezeTokensScreen { /// Handle confirmation OK action fn confirmation_ok(&mut self) -> AppAction { // Validate user input - let parsed = 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, ], - ); - if parsed.is_err() { + ) else { self.status = FreezeTokensStatus::Error; MessageBanner::set_global( self.app_context.egui_ctx(), @@ -278,8 +278,12 @@ impl FreezeTokensScreen { MessageType::Error, ); return AppAction::None; - } - let freeze_id = parsed.unwrap(); + }; + + // Validate signing key before transitioning to waiting state + let Some(signing_key) = validate_signing_key(&self.app_context, &self.selected_key) else { + return AppAction::None; + }; self.status = FreezeTokensStatus::WaitingForResult; let handle = MessageBanner::set_global( @@ -309,18 +313,6 @@ impl FreezeTokensScreen { }) }; - let signing_key = match self.selected_key.clone() { - Some(key) => key, - None => { - MessageBanner::set_global( - self.app_context.egui_ctx(), - "No signing key selected", - MessageType::Error, - ); - return AppAction::None; - } - }; - // Dispatch to backend AppAction::BackendTask(BackendTask::TokenTask(Box::new(TokenTask::FreezeTokens { actor_identity: self.identity.clone(), diff --git a/src/ui/tokens/mint_tokens_screen.rs b/src/ui/tokens/mint_tokens_screen.rs index 96773a0a0..4549cd47b 100644 --- a/src/ui/tokens/mint_tokens_screen.rs +++ b/src/ui/tokens/mint_tokens_screen.rs @@ -24,6 +24,7 @@ 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; @@ -299,15 +300,13 @@ impl MintTokensScreen { return AppAction::None; } - let parsed_receiver_id = 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, ], - ); - - if parsed_receiver_id.is_err() { + ) else { self.status = MintTokensStatus::Error; MessageBanner::set_global( self.app_context.egui_ctx(), @@ -315,9 +314,13 @@ impl MintTokensScreen { 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) else { + return AppAction::None; + }; - let receiver_id = parsed_receiver_id.unwrap(); self.status = MintTokensStatus::WaitingForResult; let handle = MessageBanner::set_global( self.app_context.egui_ctx(), @@ -345,18 +348,6 @@ impl MintTokensScreen { }) }; - let signing_key = match self.selected_key.clone() { - Some(key) => key, - None => { - MessageBanner::set_global( - self.app_context.egui_ctx(), - "No signing key selected", - MessageType::Error, - ); - return AppAction::None; - } - }; - AppAction::BackendTask(BackendTask::TokenTask(Box::new(TokenTask::MintTokens { sending_identity: self.identity_token_info.identity.clone(), data_contract, diff --git a/src/ui/tokens/mod.rs b/src/ui/tokens/mod.rs index 0143d33d1..5b5c9cd00 100644 --- a/src/ui/tokens/mod.rs +++ b/src/ui/tokens/mod.rs @@ -13,3 +13,29 @@ 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::ui::MessageType; +use crate::ui::components::MessageBanner; +use dash_sdk::platform::IdentityPublicKey; + +/// 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, +) -> 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 a129e14ca..aa3ceb980 100644 --- a/src/ui/tokens/pause_tokens_screen.rs +++ b/src/ui/tokens/pause_tokens_screen.rs @@ -21,6 +21,7 @@ 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; @@ -212,6 +213,13 @@ impl PauseTokensScreen { match dialog.show(ui).inner.dialog_response { Some(ConfirmationStatus::Confirmed) => { self.confirmation_dialog = None; + + // Validate signing key before transitioning to waiting state + let Some(signing_key) = validate_signing_key(&self.app_context, &self.selected_key) + else { + return AppAction::None; + }; + self.status = PauseTokensStatus::WaitingForResult; let handle = MessageBanner::set_global( self.app_context.egui_ctx(), @@ -241,18 +249,6 @@ impl PauseTokensScreen { }) }; - let signing_key = match self.selected_key.clone() { - Some(key) => key, - None => { - MessageBanner::set_global( - self.app_context.egui_ctx(), - "No signing key selected", - MessageType::Error, - ); - return AppAction::None; - } - }; - AppAction::BackendTask(BackendTask::TokenTask(Box::new(TokenTask::PauseTokens { actor_identity: self.identity.clone(), data_contract, diff --git a/src/ui/tokens/resume_tokens_screen.rs b/src/ui/tokens/resume_tokens_screen.rs index 1a4d585b7..1ea028e06 100644 --- a/src/ui/tokens/resume_tokens_screen.rs +++ b/src/ui/tokens/resume_tokens_screen.rs @@ -21,6 +21,7 @@ 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; @@ -212,6 +213,13 @@ impl ResumeTokensScreen { match dialog.show(ui).inner.dialog_response { Some(ConfirmationStatus::Confirmed) => { self.confirmation_dialog = None; + + // Validate signing key before transitioning to waiting state + let Some(signing_key) = validate_signing_key(&self.app_context, &self.selected_key) + else { + return AppAction::None; + }; + self.status = ResumeTokensStatus::WaitingForResult; let handle = MessageBanner::set_global( self.app_context.egui_ctx(), @@ -241,18 +249,6 @@ impl ResumeTokensScreen { }) }; - let signing_key = match self.selected_key.clone() { - Some(key) => key, - None => { - MessageBanner::set_global( - self.app_context.egui_ctx(), - "No signing key selected", - MessageType::Error, - ); - return AppAction::None; - } - }; - AppAction::BackendTask(BackendTask::TokenTask(Box::new(TokenTask::ResumeTokens { actor_identity: self.identity.clone(), data_contract, diff --git a/src/ui/tokens/set_token_price_screen.rs b/src/ui/tokens/set_token_price_screen.rs index 0c967f27d..4c4c83138 100644 --- a/src/ui/tokens/set_token_price_screen.rs +++ b/src/ui/tokens/set_token_price_screen.rs @@ -23,6 +23,7 @@ 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; @@ -728,6 +729,11 @@ impl SetTokenPriceScreen { } }; + // Validate signing key before transitioning to waiting state + let Some(signing_key) = validate_signing_key(&self.app_context, &self.selected_key) else { + return AppAction::None; + }; + // Set waiting state self.status = SetTokenPriceStatus::WaitingForResult; let handle = MessageBanner::set_global( @@ -755,18 +761,6 @@ impl SetTokenPriceScreen { }) }; - let signing_key = match self.selected_key.clone() { - Some(key) => key, - None => { - MessageBanner::set_global( - self.app_context.egui_ctx(), - "No signing key selected", - MessageType::Error, - ); - return AppAction::None; - } - }; - // Create and return the backend task AppAction::BackendTask(BackendTask::TokenTask(Box::new( TokenTask::SetDirectPurchasePrice { diff --git a/src/ui/tokens/transfer_tokens_screen.rs b/src/ui/tokens/transfer_tokens_screen.rs index dd6c46f31..1a566c283 100644 --- a/src/ui/tokens/transfer_tokens_screen.rs +++ b/src/ui/tokens/transfer_tokens_screen.rs @@ -34,6 +34,7 @@ use std::collections::HashSet; use std::sync::{Arc, RwLock}; use crate::ui::identities::get_selected_wallet; +use crate::ui::tokens::validate_signing_key; use super::tokens_screen::IdentityTokenBalance; @@ -215,15 +216,13 @@ impl TransferTokensScreen { return AppAction::None; } - let parsed_receiver_id = 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, ], - ); - - if parsed_receiver_id.is_err() { + ) else { self.transfer_tokens_status = TransferTokensStatus::Error; MessageBanner::set_global( self.app_context.egui_ctx(), @@ -231,17 +230,12 @@ impl TransferTokensScreen { MessageType::Error, ); return AppAction::None; - } + }; - let receiver_id = parsed_receiver_id.unwrap(); - 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); + // Validate signing key before transitioning to waiting state + let Some(signing_key) = validate_signing_key(&self.app_context, &self.selected_key) else { + return AppAction::None; + }; let data_contract = match self .app_context @@ -258,17 +252,14 @@ impl TransferTokensScreen { } }; - let signing_key = match self.selected_key.clone() { - Some(key) => key, - None => { - MessageBanner::set_global( - self.app_context.egui_ctx(), - "No signing key selected", - 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 { diff --git a/src/ui/tokens/unfreeze_tokens_screen.rs b/src/ui/tokens/unfreeze_tokens_screen.rs index 5168cdb9b..d365a02e5 100644 --- a/src/ui/tokens/unfreeze_tokens_screen.rs +++ b/src/ui/tokens/unfreeze_tokens_screen.rs @@ -22,6 +22,7 @@ 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; @@ -265,14 +266,13 @@ impl UnfreezeTokensScreen { fn confirmation_ok(&mut self) -> AppAction { // Validate user input - let parsed = 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, ], - ); - if parsed.is_err() { + ) else { self.status = UnfreezeTokensStatus::Error; MessageBanner::set_global( self.app_context.egui_ctx(), @@ -280,8 +280,12 @@ impl UnfreezeTokensScreen { MessageType::Error, ); return AppAction::None; - } - let unfreeze_id = parsed.unwrap(); + }; + + // Validate signing key before transitioning to waiting state + let Some(signing_key) = validate_signing_key(&self.app_context, &self.selected_key) else { + return AppAction::None; + }; self.status = UnfreezeTokensStatus::WaitingForResult; let handle = MessageBanner::set_global( @@ -311,18 +315,6 @@ impl UnfreezeTokensScreen { }) }; - let signing_key = match self.selected_key.clone() { - Some(key) => key, - None => { - MessageBanner::set_global( - self.app_context.egui_ctx(), - "No signing key selected", - MessageType::Error, - ); - return AppAction::None; - } - }; - // Dispatch to backend AppAction::BackendTask(BackendTask::TokenTask(Box::new( TokenTask::UnfreezeTokens { diff --git a/src/ui/tokens/update_token_config.rs b/src/ui/tokens/update_token_config.rs index a693b79f5..9a1583b6c 100644 --- a/src/ui/tokens/update_token_config.rs +++ b/src/ui/tokens/update_token_config.rs @@ -19,6 +19,7 @@ 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; @@ -766,7 +767,9 @@ impl UpdateTokenConfigScreen { }) }; - if let Some(signing_key) = self.signing_key.clone() { + if let Some(signing_key) = + validate_signing_key(&self.app_context, &self.signing_key) + { self.update_status = UpdateTokenConfigStatus::Updating(Utc::now()); action |= AppAction::BackendTask(BackendTask::TokenTask(Box::new( TokenTask::UpdateTokenConfig { @@ -781,12 +784,6 @@ impl UpdateTokenConfigScreen { group_info, }, ))); - } else { - MessageBanner::set_global( - ui.ctx(), - "No signing key selected", - MessageType::Error, - ); } } } From a8b72696a7fc8189e6deda578d497b1d41f19f18 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 27 Feb 2026 13:03:55 +0100 Subject: [PATCH 34/46] refactor(ui): return Result from get_selected_wallet Replace &mut Option error out-parameter with idiomatic Result>>, String>. Update 26+ callsites across identity, token, DashPay, and contract screens. Callsite patterns: unwrap_or_else with MessageBanner for user-visible errors, unwrap_or(None) where errors were previously silently ignored. Co-Authored-By: Claude Opus 4.6 --- .../document_action_screen.rs | 23 ++++---- .../register_contract_screen.rs | 19 +++---- .../update_contract_screen.rs | 22 ++++--- src/ui/dashpay/add_contact_screen.rs | 10 +--- src/ui/dashpay/contact_info_editor.rs | 3 +- src/ui/dashpay/contact_requests.rs | 16 ++---- src/ui/dashpay/profile_screen.rs | 31 +++++----- src/ui/dashpay/qr_code_generator.rs | 8 +-- src/ui/dashpay/qr_scanner.rs | 5 +- src/ui/identities/keys/add_key_screen.rs | 30 +++++----- src/ui/identities/mod.rs | 57 +++++++------------ .../identities/register_dpns_name_screen.rs | 35 ++++++------ src/ui/identities/transfer_screen.rs | 10 ++-- src/ui/identities/withdraw_screen.rs | 10 ++-- src/ui/tokens/burn_tokens_screen.rs | 16 ++---- src/ui/tokens/claim_tokens_screen.rs | 10 ++-- src/ui/tokens/destroy_frozen_funds_screen.rs | 16 ++---- src/ui/tokens/direct_token_purchase_screen.rs | 17 ++---- src/ui/tokens/freeze_tokens_screen.rs | 16 ++---- src/ui/tokens/mint_tokens_screen.rs | 16 ++---- src/ui/tokens/pause_tokens_screen.rs | 16 ++---- src/ui/tokens/resume_tokens_screen.rs | 16 ++---- src/ui/tokens/set_token_price_screen.rs | 17 +++--- src/ui/tokens/tokens_screen/token_creator.rs | 11 ++-- src/ui/tokens/transfer_tokens_screen.rs | 10 ++-- src/ui/tokens/unfreeze_tokens_screen.rs | 16 ++---- src/ui/tokens/update_token_config.rs | 17 ++---- 27 files changed, 197 insertions(+), 276 deletions(-) diff --git a/src/ui/contracts_documents/document_action_screen.rs b/src/ui/contracts_documents/document_action_screen.rs index 7bc284a04..58d841e8f 100644 --- a/src/ui/contracts_documents/document_action_screen.rs +++ b/src/ui/contracts_documents/document_action_screen.rs @@ -267,12 +267,11 @@ 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) + .unwrap_or_else(|e| { + self.backend_message = Some(e); + None + }); } else { self.selected_key = None; self.wallet = None; @@ -1765,12 +1764,12 @@ 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) + .unwrap_or_else(|e| { + self.backend_message = Some(e); + None + }); } if let Some(wallet) = &self.wallet { if let Err(e) = try_open_wallet_no_password(wallet) { diff --git a/src/ui/contracts_documents/register_contract_screen.rs b/src/ui/contracts_documents/register_contract_screen.rs index e0c132b3f..f99e76861 100644 --- a/src/ui/contracts_documents/register_contract_screen.rs +++ b/src/ui/contracts_documents/register_contract_screen.rs @@ -64,17 +64,15 @@ 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).unwrap_or_else(|e| { + MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error); + None + }) } else { None }; - if let Some(err) = error_message { - MessageBanner::set_global(app_context.egui_ctx(), &err, MessageType::Error); - } - // Auto-select a suitable key for contract registration use dash_sdk::dpp::identity::KeyType; let selected_key = selected_qualified_identity.as_ref().and_then(|identity| { @@ -479,16 +477,15 @@ impl ScreenLike for RegisterDataContractScreen { .cloned(); // Update wallet - let mut wallet_error = None; self.selected_wallet = get_selected_wallet( identity, Some(&self.app_context), None, - &mut wallet_error, - ); - if let Some(e) = wallet_error { + ) + .unwrap_or_else(|e| { MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); - } + None + }); // Re-parse contract with new owner ID self.parse_contract(); diff --git a/src/ui/contracts_documents/update_contract_screen.rs b/src/ui/contracts_documents/update_contract_screen.rs index d94bb4434..e6e4fc81c 100644 --- a/src/ui/contracts_documents/update_contract_screen.rs +++ b/src/ui/contracts_documents/update_contract_screen.rs @@ -67,9 +67,11 @@ 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).unwrap_or_else(|e| { + MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error); + None + }) } else { None }; @@ -494,16 +496,12 @@ impl ScreenLike for UpdateDataContractScreen { .cloned(); // Update wallet - let mut wallet_error = None; - self.selected_wallet = get_selected_wallet( - identity, - Some(&self.app_context), - None, - &mut wallet_error, - ); - if let Some(e) = wallet_error { - MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); - } + self.selected_wallet = + get_selected_wallet(identity, Some(&self.app_context), None) + .unwrap_or_else(|e| { + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + None + }); // Re-parse contract with new owner ID self.parse_contract(); diff --git a/src/ui/dashpay/add_contact_screen.rs b/src/ui/dashpay/add_contact_screen.rs index 769686be9..8315b5541 100644 --- a/src/ui/dashpay/add_contact_screen.rs +++ b/src/ui/dashpay/add_contact_screen.rs @@ -285,13 +285,9 @@ 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) + .unwrap_or(None); } } else { self.selected_key = None; diff --git a/src/ui/dashpay/contact_info_editor.rs b/src/ui/dashpay/contact_info_editor.rs index b3d62fcd7..9119b01fb 100644 --- a/src/ui/dashpay/contact_info_editor.rs +++ b/src/ui/dashpay/contact_info_editor.rs @@ -51,9 +51,8 @@ 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); + get_selected_wallet(&identity, Some(&app_context), None).unwrap_or(None); Self { app_context, diff --git a/src/ui/dashpay/contact_requests.rs b/src/ui/dashpay/contact_requests.rs index 77dc1f224..a093d65b3 100644 --- a/src/ui/dashpay/contact_requests.rs +++ b/src/ui/dashpay/contact_requests.rs @@ -102,9 +102,8 @@ 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).unwrap_or(None); // Load requests from database for this identity new_self.load_requests_from_database(); @@ -130,9 +129,8 @@ 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); + get_selected_wallet(id, Some(&self.app_context), None).unwrap_or(None); } else { self.selected_identity_string.clear(); self.selected_wallet = None; @@ -575,13 +573,9 @@ impl ContactRequests { // 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) + .unwrap_or(None); } else { self.selected_wallet = None; } diff --git a/src/ui/dashpay/profile_screen.rs b/src/ui/dashpay/profile_screen.rs index bc99b8978..10bbf3f72 100644 --- a/src/ui/dashpay/profile_screen.rs +++ b/src/ui/dashpay/profile_screen.rs @@ -11,7 +11,6 @@ use crate::ui::components::component_trait::Component; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::identity_selector::IdentitySelector; use crate::ui::components::info_popup::InfoPopup; -use crate::ui::components::message_banner::MessageBanner; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; @@ -202,13 +201,12 @@ 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) + .unwrap_or_else(|e| { + MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error); + None + }); // Load profile from database for this identity new_self.load_profile_from_database(); @@ -634,13 +632,16 @@ impl ProfileScreen { // 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) + .unwrap_or_else(|e| { + MessageBanner::set_global( + self.app_context.egui_ctx(), + &e, + MessageType::Error, + ); + None + }); } else { self.selected_wallet = None; } diff --git a/src/ui/dashpay/qr_code_generator.rs b/src/ui/dashpay/qr_code_generator.rs index 155b9738b..8857ea571 100644 --- a/src/ui/dashpay/qr_code_generator.rs +++ b/src/ui/dashpay/qr_code_generator.rs @@ -76,9 +76,8 @@ 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).unwrap_or(None); } new_self @@ -202,13 +201,12 @@ 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, - ); + ) + .unwrap_or(None); } else { self.selected_wallet = None; } diff --git a/src/ui/dashpay/qr_scanner.rs b/src/ui/dashpay/qr_scanner.rs index db456632a..3fc72b5a4 100644 --- a/src/ui/dashpay/qr_scanner.rs +++ b/src/ui/dashpay/qr_scanner.rs @@ -184,13 +184,12 @@ 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, - ); + ) + .unwrap_or(None); } else { self.selected_wallet = None; } diff --git a/src/ui/identities/keys/add_key_screen.rs b/src/ui/identities/keys/add_key_screen.rs index 733defd6d..6e942c87e 100644 --- a/src/ui/identities/keys/add_key_screen.rs +++ b/src/ui/identities/keys/add_key_screen.rs @@ -65,11 +65,11 @@ impl AddKeyScreen { KeyType::all_key_types().into(), false, ); - let mut wallet_error = None; - let selected_wallet = get_selected_wallet(&identity, None, selected_key, &mut wallet_error); - if let Some(e) = wallet_error { - MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error); - } + let selected_wallet = + get_selected_wallet(&identity, None, selected_key).unwrap_or_else(|e| { + MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error); + None + }); Self { identity, @@ -102,11 +102,11 @@ impl AddKeyScreen { KeyType::all_key_types().into(), false, ); - let mut wallet_error = None; - let selected_wallet = get_selected_wallet(&identity, None, selected_key, &mut wallet_error); - if let Some(e) = wallet_error { - MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error); - } + let selected_wallet = + get_selected_wallet(&identity, None, selected_key).unwrap_or_else(|e| { + MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error); + None + }); let dashpay_contract_id = app_context .dashpay_contract @@ -144,11 +144,11 @@ impl AddKeyScreen { KeyType::all_key_types().into(), false, ); - let mut wallet_error = None; - let selected_wallet = get_selected_wallet(&identity, None, selected_key, &mut wallet_error); - if let Some(e) = wallet_error { - MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error); - } + let selected_wallet = + get_selected_wallet(&identity, None, selected_key).unwrap_or_else(|e| { + MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error); + None + }); let dashpay_contract_id = app_context .dashpay_contract 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 3dd21be1c..43e8ad054 100644 --- a/src/ui/identities/register_dpns_name_screen.rs +++ b/src/ui/identities/register_dpns_name_screen.rs @@ -69,15 +69,14 @@ impl RegisterDpnsNameScreen { app_context.load_local_user_identities().unwrap_or_default(); let selected_qualified_identity = qualified_identities.first().cloned(); - let mut wallet_error: Option = None; let selected_wallet = if let Some(ref identity) = selected_qualified_identity { - get_selected_wallet(identity, Some(app_context), None, &mut wallet_error) + get_selected_wallet(identity, Some(app_context), None).unwrap_or_else(|e| { + MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error); + None + }) } else { None }; - if let Some(e) = wallet_error { - MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error); - } // Auto-select a suitable key for DPNS registration // Note: MASTER keys cannot be used for document operations, @@ -162,12 +161,11 @@ impl RegisterDpnsNameScreen { .cloned(); // Update the selected wallet - let mut wallet_error = None; - self.selected_wallet = - get_selected_wallet(qi, Some(&self.app_context), None, &mut wallet_error); - if let Some(e) = wallet_error { - MessageBanner::set_global(self.app_context.egui_ctx(), &e, MessageType::Error); - } + self.selected_wallet = get_selected_wallet(qi, Some(&self.app_context), None) + .unwrap_or_else(|e| { + MessageBanner::set_global(self.app_context.egui_ctx(), &e, MessageType::Error); + None + }); } else { // If not found, you might want to handle this case // For now, we'll set selected_qualified_identity to None @@ -218,12 +216,15 @@ impl RegisterDpnsNameScreen { .cloned(); // Update wallet - let mut wallet_error = None; - self.selected_wallet = - get_selected_wallet(identity, Some(&self.app_context), None, &mut wallet_error); - if let Some(e) = wallet_error { - MessageBanner::set_global(self.app_context.egui_ctx(), &e, MessageType::Error); - } + self.selected_wallet = get_selected_wallet(identity, Some(&self.app_context), None) + .unwrap_or_else(|e| { + MessageBanner::set_global( + self.app_context.egui_ctx(), + &e, + MessageType::Error, + ); + None + }); } else { self.selected_key = None; self.selected_wallet = None; diff --git a/src/ui/identities/transfer_screen.rs b/src/ui/identities/transfer_screen.rs index c366cf40a..fd0798258 100644 --- a/src/ui/identities/transfer_screen.rs +++ b/src/ui/identities/transfer_screen.rs @@ -91,11 +91,11 @@ impl TransferScreen { KeyType::all_key_types().into(), false, ); - let mut wallet_error = None; - let selected_wallet = get_selected_wallet(&identity, None, selected_key, &mut wallet_error); - if let Some(e) = wallet_error { - MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error); - } + let selected_wallet = + 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(), diff --git a/src/ui/identities/withdraw_screen.rs b/src/ui/identities/withdraw_screen.rs index 74ff5472a..64d67a72c 100644 --- a/src/ui/identities/withdraw_screen.rs +++ b/src/ui/identities/withdraw_screen.rs @@ -73,11 +73,11 @@ impl WithdrawalScreen { KeyType::all_key_types().into(), false, ); - let mut wallet_error = None; - let selected_wallet = get_selected_wallet(&identity, None, selected_key, &mut wallet_error); - if let Some(e) = wallet_error { - MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error); - } + let selected_wallet = + 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(), diff --git a/src/ui/tokens/burn_tokens_screen.rs b/src/ui/tokens/burn_tokens_screen.rs index 685659d57..5471e4e8e 100644 --- a/src/ui/tokens/burn_tokens_screen.rs +++ b/src/ui/tokens/burn_tokens_screen.rs @@ -193,16 +193,12 @@ impl BurnTokensScreen { }; // 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, diff --git a/src/ui/tokens/claim_tokens_screen.rs b/src/ui/tokens/claim_tokens_screen.rs index 2dc494ad0..3fe5a61aa 100644 --- a/src/ui/tokens/claim_tokens_screen.rs +++ b/src/ui/tokens/claim_tokens_screen.rs @@ -108,11 +108,11 @@ impl ClaimTokensScreen { ); } - let mut wallet_error = None; - let selected_wallet = get_selected_wallet(&identity, None, possible_key, &mut wallet_error); - if let Some(e) = wallet_error { - MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error); - } + let selected_wallet = + get_selected_wallet(&identity, None, possible_key).unwrap_or_else(|e| { + MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error); + None + }); let distribution_type = match ( token_configuration diff --git a/src/ui/tokens/destroy_frozen_funds_screen.rs b/src/ui/tokens/destroy_frozen_funds_screen.rs index 78ce708c4..3d068b387 100644 --- a/src/ui/tokens/destroy_frozen_funds_screen.rs +++ b/src/ui/tokens/destroy_frozen_funds_screen.rs @@ -189,16 +189,12 @@ impl DestroyFrozenFundsScreen { }; // 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 + }); let all_identities = app_context .load_local_qualified_identities() diff --git a/src/ui/tokens/direct_token_purchase_screen.rs b/src/ui/tokens/direct_token_purchase_screen.rs index 72ddbec04..9a2b19421 100644 --- a/src/ui/tokens/direct_token_purchase_screen.rs +++ b/src/ui/tokens/direct_token_purchase_screen.rs @@ -89,18 +89,13 @@ impl PurchaseTokenScreen { ) .cloned(); - let mut wallet_error = None; - // Attempt to get an unlocked wallet reference - let selected_wallet = get_selected_wallet( - &identity_token_info.identity, - None, - possible_key.as_ref(), - &mut wallet_error, - ); - if let Some(e) = wallet_error { - MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error); - } + 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, diff --git a/src/ui/tokens/freeze_tokens_screen.rs b/src/ui/tokens/freeze_tokens_screen.rs index 0098bc629..61995ae25 100644 --- a/src/ui/tokens/freeze_tokens_screen.rs +++ b/src/ui/tokens/freeze_tokens_screen.rs @@ -189,16 +189,12 @@ impl FreezeTokensScreen { }; // 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: identity_token_info.identity.clone(), diff --git a/src/ui/tokens/mint_tokens_screen.rs b/src/ui/tokens/mint_tokens_screen.rs index 4549cd47b..542c3c7c2 100644 --- a/src/ui/tokens/mint_tokens_screen.rs +++ b/src/ui/tokens/mint_tokens_screen.rs @@ -191,16 +191,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, diff --git a/src/ui/tokens/pause_tokens_screen.rs b/src/ui/tokens/pause_tokens_screen.rs index aa3ceb980..606f76e3b 100644 --- a/src/ui/tokens/pause_tokens_screen.rs +++ b/src/ui/tokens/pause_tokens_screen.rs @@ -172,16 +172,12 @@ impl PauseTokensScreen { }; // 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: identity_token_info.identity.clone(), diff --git a/src/ui/tokens/resume_tokens_screen.rs b/src/ui/tokens/resume_tokens_screen.rs index 1ea028e06..f198b1689 100644 --- a/src/ui/tokens/resume_tokens_screen.rs +++ b/src/ui/tokens/resume_tokens_screen.rs @@ -171,16 +171,12 @@ impl ResumeTokensScreen { }; // 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: identity_token_info.identity.clone(), diff --git a/src/ui/tokens/set_token_price_screen.rs b/src/ui/tokens/set_token_price_screen.rs index 4c4c83138..bb525f9a5 100644 --- a/src/ui/tokens/set_token_price_screen.rs +++ b/src/ui/tokens/set_token_price_screen.rs @@ -256,16 +256,13 @@ impl SetTokenPriceScreen { }; // 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, - &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).unwrap_or_else( + |e| { + set_error_banner(&e); + None + }, + ); Self { identity_token_info: identity_token_info.clone(), diff --git a/src/ui/tokens/tokens_screen/token_creator.rs b/src/ui/tokens/tokens_screen/token_creator.rs index b39f04ba1..c110e3f86 100644 --- a/src/ui/tokens/tokens_screen/token_creator.rs +++ b/src/ui/tokens/tokens_screen/token_creator.rs @@ -969,12 +969,11 @@ impl TokensScreen { fn update_selected_wallet(&mut self) { if let (Some(qid), Some(key)) = (&self.selected_identity, &self.selected_key) { - let mut wallet_error = None; - self.selected_wallet = - crate::ui::identities::get_selected_wallet(qid, None, Some(key), &mut wallet_error); - if let Some(e) = wallet_error { - MessageBanner::set_global(self.app_context.egui_ctx(), &e, MessageType::Error); - } + 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 + }); } } diff --git a/src/ui/tokens/transfer_tokens_screen.rs b/src/ui/tokens/transfer_tokens_screen.rs index 1a566c283..ce0e02e85 100644 --- a/src/ui/tokens/transfer_tokens_screen.rs +++ b/src/ui/tokens/transfer_tokens_screen.rs @@ -97,11 +97,11 @@ impl TransferTokensScreen { KeyType::all_key_types().into(), false, ); - let mut wallet_error = None; - let selected_wallet = get_selected_wallet(&identity, None, selected_key, &mut wallet_error); - if let Some(e) = wallet_error { - MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error); - } + let selected_wallet = + get_selected_wallet(&identity, None, selected_key).unwrap_or_else(|e| { + MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error); + None + }); let amount = Some(Amount::from(&identity_token_balance).with_value(0)); diff --git a/src/ui/tokens/unfreeze_tokens_screen.rs b/src/ui/tokens/unfreeze_tokens_screen.rs index d365a02e5..d90c78159 100644 --- a/src/ui/tokens/unfreeze_tokens_screen.rs +++ b/src/ui/tokens/unfreeze_tokens_screen.rs @@ -194,16 +194,12 @@ impl UnfreezeTokensScreen { }; // 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: identity_token_info.identity.clone(), diff --git a/src/ui/tokens/update_token_config.rs b/src/ui/tokens/update_token_config.rs index 9a1583b6c..817412343 100644 --- a/src/ui/tokens/update_token_config.rs +++ b/src/ui/tokens/update_token_config.rs @@ -87,8 +87,6 @@ impl UpdateTokenConfigScreen { ) .cloned(); - let mut wallet_error = None; - // Initialize with no group - will be set when user selects a change item let group = None; @@ -96,15 +94,12 @@ 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 wallet_error, - ); - if let Some(e) = wallet_error { - MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error); - } + 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: identity_token_info.clone(), From b0d76d76ef995b5ea482ab18da3ab3897d996b25 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 27 Feb 2026 13:08:43 +0100 Subject: [PATCH 35/46] fix(ui): resolve duplicate imports and clippy warnings Remove duplicate MessageBanner imports in create_asset_lock_screen and wallets_screen/mod. Fix needless_borrows_for_generic_args clippy lints in profile_screen, transfer_screen, and wallets_screen. Co-Authored-By: Claude Opus 4.6 --- src/ui/dashpay/profile_screen.rs | 2 +- src/ui/identities/transfer_screen.rs | 2 +- src/ui/wallets/create_asset_lock_screen.rs | 1 - src/ui/wallets/wallets_screen/mod.rs | 3 +-- 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/ui/dashpay/profile_screen.rs b/src/ui/dashpay/profile_screen.rs index 10bbf3f72..bb1ae9c29 100644 --- a/src/ui/dashpay/profile_screen.rs +++ b/src/ui/dashpay/profile_screen.rs @@ -395,7 +395,7 @@ impl ProfileScreen { if !self.is_valid() { MessageBanner::set_global( self.app_context.egui_ctx(), - &self.validation_errors[0].message(), + self.validation_errors[0].message(), MessageType::Error, ); return AppAction::None; diff --git a/src/ui/identities/transfer_screen.rs b/src/ui/identities/transfer_screen.rs index fd0798258..ce731578d 100644 --- a/src/ui/identities/transfer_screen.rs +++ b/src/ui/identities/transfer_screen.rs @@ -530,7 +530,7 @@ impl ScreenLike for TransferScreen { .unwrap_or_else(|e| { MessageBanner::set_global( self.app_context.egui_ctx(), - &format!("Failed to load local identities: {e}"), + format!("Failed to load local identities: {e}"), MessageType::Error, ); vec![] diff --git a/src/ui/wallets/create_asset_lock_screen.rs b/src/ui/wallets/create_asset_lock_screen.rs index 6c45c9451..4f6b3aaba 100644 --- a/src/ui/wallets/create_asset_lock_screen.rs +++ b/src/ui/wallets/create_asset_lock_screen.rs @@ -10,7 +10,6 @@ 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; -use crate::ui::components::message_banner::MessageBanner; use crate::ui::components::styled::island_central_panel; use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index da81c818d..48d63be39 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -15,7 +15,6 @@ 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; -use crate::ui::components::message_banner::MessageBanner; use crate::ui::components::styled::island_central_panel; use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{WalletUnlockPopup, WalletUnlockResult}; @@ -2095,7 +2094,7 @@ impl ScreenLike for WalletsBalancesScreen { self.refreshing = false; MessageBanner::set_global( self.app_context.egui_ctx(), - &format!("Mined {} block(s)", count), + format!("Mined {} block(s)", count), MessageType::Success, ); } From dcdee85e5c69670d3abe3498141dbf7f76067f53 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 27 Feb 2026 13:30:37 +0100 Subject: [PATCH 36/46] style: reorder imports in masternode_list_diff_screen Co-Authored-By: Claude Opus 4.6 --- src/ui/tools/masternode_list_diff_screen.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/tools/masternode_list_diff_screen.rs b/src/ui/tools/masternode_list_diff_screen.rs index 10438906a..3ccc851f6 100644 --- a/src/ui/tools/masternode_list_diff_screen.rs +++ b/src/ui/tools/masternode_list_diff_screen.rs @@ -4,12 +4,12 @@ use crate::backend_task::mnlist::MnListTask; use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::components::core_p2p_handler::CoreP2PHandler; 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::components::MessageBanner; use crate::ui::{MessageType, RootScreenType, ScreenLike}; use dash_sdk::dashcore_rpc::RpcApi; use dash_sdk::dashcore_rpc::json::QuorumType; From 9191fcefa9b5b63b0a44bb36041570802d38f235 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:02:28 +0100 Subject: [PATCH 37/46] fix(ui): address review findings from MessageBanner migration audit Apply 13 triaged review fixes plus 1 bug fix across 22 files: - Remove dead error state fields (backend_message, error_message, Error variant) - Replace .expect() panics with graceful fallback + MessageBanner in token screens - Fix missing MessageBanner::show_global() on contracts documents screen - Migrate DocumentActionScreen inline errors to MessageBanner - Replace unwrap_or(None) with error-reporting fallback in DashPay screens - Fix replace_global idempotency and use relaxed atomic ordering in banner - Extract shared set_error_banner helper for 8 token screens - Restore correct Some(0) wallet index semantics - Document BannerHandle lifecycle in CLAUDE.md Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 2 + src/ui/components/message_banner.rs | 18 ++-- .../contracts_documents_screen.rs | 1 + .../document_action_screen.rs | 89 ++++++++++--------- src/ui/dashpay/contact_info_editor.rs | 7 +- src/ui/dashpay/contact_requests.rs | 25 +++++- src/ui/dashpay/qr_code_generator.rs | 14 ++- .../add_existing_identity_screen.rs | 5 +- .../identities/add_new_identity_screen/mod.rs | 17 ---- src/ui/tokens/add_token_by_id_screen.rs | 44 +++++---- src/ui/tokens/burn_tokens_screen.rs | 4 +- src/ui/tokens/claim_tokens_screen.rs | 16 +++- src/ui/tokens/destroy_frozen_funds_screen.rs | 4 +- src/ui/tokens/freeze_tokens_screen.rs | 4 +- src/ui/tokens/mint_tokens_screen.rs | 4 +- src/ui/tokens/mod.rs | 8 ++ src/ui/tokens/pause_tokens_screen.rs | 4 +- src/ui/tokens/resume_tokens_screen.rs | 4 +- src/ui/tokens/set_token_price_screen.rs | 4 +- src/ui/tokens/transfer_tokens_screen.rs | 15 +++- src/ui/tokens/unfreeze_tokens_screen.rs | 4 +- src/ui/wallets/wallets_screen/mod.rs | 2 +- 22 files changed, 170 insertions(+), 125 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5a56515e3..bbd35f03a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -175,6 +175,8 @@ response.inner.update(&mut self.amount); 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), clear the handle (`self.refresh_banner = None`) to dismiss the progress banner. AppState handles displaying the actual result banner. + ## Database Single SQLite connection wrapped in `Mutex`. Schema initialized in `database/initialization.rs`. Domain modules provide typed CRUD methods. Backend task errors use `TaskError` (`src/backend_task/error.rs`) — see App Task System section above. diff --git a/src/ui/components/message_banner.rs b/src/ui/components/message_banner.rs index 807e08009..f48a7b665 100644 --- a/src/ui/components/message_banner.rs +++ b/src/ui/components/message_banner.rs @@ -17,9 +17,10 @@ const DETAILS_MAX_HEIGHT: f32 = 120.0; static BANNER_KEY_COUNTER: AtomicU64 = AtomicU64::new(0); fn next_banner_key() -> u64 { - // SeqCst ensures total ordering of key generation, future-proofing - // against potential multi-threaded banner usage. - BANNER_KEY_COUNTER.fetch_add(1, Ordering::SeqCst) + // 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) } /// The domain type for `MessageBanner`, representing the banner's lifecycle state. @@ -197,7 +198,7 @@ impl BannerHandle { /// /// Returns `None` if the banner no longer exists. pub fn with_details(&self, details: impl fmt::Debug) -> Option<&Self> { - let details = format!("{:#?}", details); + let details = format!("{:?}", details); if details.is_empty() { return Some(self); } @@ -342,6 +343,10 @@ impl MessageBanner { /// `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). + /// /// Returns a [`BannerHandle`] for updating or clearing the banner later. pub fn replace_global( ctx: &egui::Context, @@ -363,9 +368,10 @@ impl MessageBanner { if let Some(b) = banners.iter_mut().find(|b| b.text == old_text) { key = b.key; b.reset_to(new_text, message_type); - } else if let Some(existing) = banners.iter_mut().find(|b| b.text == new_text) { + } 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, message_type); } else { key = next_banner_key(); banners.push(BannerState::new(key, new_text, message_type)); diff --git a/src/ui/contracts_documents/contracts_documents_screen.rs b/src/ui/contracts_documents/contracts_documents_screen.rs index 92d3a260d..0600ce856 100644 --- a/src/ui/contracts_documents/contracts_documents_screen.rs +++ b/src/ui/contracts_documents/contracts_documents_screen.rs @@ -724,6 +724,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 58d841e8f..a10ba8e8b 100644 --- a/src/ui/contracts_documents/document_action_screen.rs +++ b/src/ui/contracts_documents/document_action_screen.rs @@ -7,9 +7,11 @@ 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; +use crate::ui::components::message_banner::MessageBanner; use crate::ui::components::styled::{island_central_panel, styled_text_edit_singleline}; use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{ @@ -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; @@ -269,7 +268,11 @@ impl DocumentActionScreen { // Update wallet self.wallet = get_selected_wallet(identity, Some(&self.app_context), None) .unwrap_or_else(|e| { - self.backend_message = Some(e); + MessageBanner::set_global( + self.app_context.egui_ctx(), + &e, + MessageType::Error, + ); None }); } else { @@ -452,9 +455,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."); } @@ -514,7 +515,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, + ); } } @@ -579,7 +584,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, + ); } } }); @@ -918,7 +927,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( @@ -998,7 +1006,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 } } @@ -1093,7 +1105,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 } } @@ -1702,28 +1718,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; } _ => {} @@ -1767,13 +1788,13 @@ impl DocumentActionScreen { self.wallet = get_selected_wallet(selected_identity, Some(&self.app_context), None) .unwrap_or_else(|e| { - self.backend_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); 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); @@ -1795,26 +1816,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/dashpay/contact_info_editor.rs b/src/ui/dashpay/contact_info_editor.rs index 9119b01fb..c8ca4e577 100644 --- a/src/ui/dashpay/contact_info_editor.rs +++ b/src/ui/dashpay/contact_info_editor.rs @@ -51,8 +51,11 @@ impl ContactInfoEditorScreen { contact_id: Identifier, ) -> Self { // Get wallet for the identity - let selected_wallet = - get_selected_wallet(&identity, Some(&app_context), None).unwrap_or(None); + let selected_wallet = get_selected_wallet(&identity, Some(&app_context), None) + .unwrap_or_else(|e| { + MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error); + None + }); Self { app_context, diff --git a/src/ui/dashpay/contact_requests.rs b/src/ui/dashpay/contact_requests.rs index a093d65b3..4be3d0f0c 100644 --- a/src/ui/dashpay/contact_requests.rs +++ b/src/ui/dashpay/contact_requests.rs @@ -103,7 +103,10 @@ impl ContactRequests { // Get wallet for the selected identity new_self.selected_wallet = - get_selected_wallet(&identities[0], Some(&app_context), None).unwrap_or(None); + get_selected_wallet(&identities[0], Some(&app_context), None).unwrap_or_else(|e| { + MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error); + None + }); // Load requests from database for this identity new_self.load_requests_from_database(); @@ -129,8 +132,15 @@ impl ContactRequests { .to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58); // Update wallet for the newly selected identity - self.selected_wallet = - get_selected_wallet(id, Some(&self.app_context), None).unwrap_or(None); + self.selected_wallet = get_selected_wallet(id, Some(&self.app_context), None) + .unwrap_or_else(|e| { + MessageBanner::set_global( + self.app_context.egui_ctx(), + &e, + MessageType::Error, + ); + None + }); } else { self.selected_identity_string.clear(); self.selected_wallet = None; @@ -575,7 +585,14 @@ impl ContactRequests { if let Some(identity) = &self.selected_identity { self.selected_wallet = get_selected_wallet(identity, Some(&self.app_context), None) - .unwrap_or(None); + .unwrap_or_else(|e| { + MessageBanner::set_global( + self.app_context.egui_ctx(), + &e, + MessageType::Error, + ); + None + }); } else { self.selected_wallet = None; } diff --git a/src/ui/dashpay/qr_code_generator.rs b/src/ui/dashpay/qr_code_generator.rs index 8857ea571..e3f904fd5 100644 --- a/src/ui/dashpay/qr_code_generator.rs +++ b/src/ui/dashpay/qr_code_generator.rs @@ -77,7 +77,10 @@ impl QRCodeGeneratorScreen { // Get wallet for the selected identity new_self.selected_wallet = - get_selected_wallet(&identities[0], Some(&app_context), None).unwrap_or(None); + get_selected_wallet(&identities[0], Some(&app_context), None).unwrap_or_else(|e| { + MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error); + None + }); } new_self @@ -206,7 +209,14 @@ impl QRCodeGeneratorScreen { Some(&self.app_context), None, ) - .unwrap_or(None); + .unwrap_or_else(|e| { + MessageBanner::set_global( + self.app_context.egui_ctx(), + &e, + MessageType::Error, + ); + None + }); } else { self.selected_wallet = None; } diff --git a/src/ui/identities/add_existing_identity_screen.rs b/src/ui/identities/add_existing_identity_screen.rs index 38209a825..6711f7d78 100644 --- a/src/ui/identities/add_existing_identity_screen.rs +++ b/src/ui/identities/add_existing_identity_screen.rs @@ -86,7 +86,6 @@ enum WalletIdentitySearchMode { pub enum AddIdentityStatus { NotStarted, WaitingForResult, - Error, Complete, } @@ -693,7 +692,7 @@ impl AddExistingIdentityScreen { )); } else { // Handle invalid index input - self.add_identity_status = AddIdentityStatus::Error; + self.add_identity_status = AddIdentityStatus::NotStarted; MessageBanner::set_global( self.app_context.egui_ctx(), "Invalid identity index", @@ -972,7 +971,7 @@ impl ScreenLike for AddExistingIdentityScreen { if let Some(handle) = self.refresh_banner.take() { handle.clear(); } - self.add_identity_status = AddIdentityStatus::Error; + self.add_identity_status = AddIdentityStatus::NotStarted; } MessageType::Success => { // Check if this is a final success message or a progress update diff --git a/src/ui/identities/add_new_identity_screen/mod.rs b/src/ui/identities/add_new_identity_screen/mod.rs index a3859a02a..5064bda17 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, @@ -1111,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 { diff --git a/src/ui/tokens/add_token_by_id_screen.rs b/src/ui/tokens/add_token_by_id_screen.rs index 5a7f3a1b6..964784ccd 100644 --- a/src/ui/tokens/add_token_by_id_screen.rs +++ b/src/ui/tokens/add_token_by_id_screen.rs @@ -32,7 +32,7 @@ enum AddTokenStatus { Searching(u32), FoundSingle(Box), FoundMultiple(Vec), - Error(String), + Error, Complete, } @@ -89,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; } } } @@ -194,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; } @@ -230,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 { @@ -261,21 +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.status = AddTokenStatus::Error; } MessageType::Info => {} } @@ -371,10 +381,6 @@ impl ScreenLike for AddTokenByIdScreen { ui.add_space(10.0); self.render_search_results(ui); - if let AddTokenStatus::Error(msg) = &self.status { - MessageBanner::set_global(ui.ctx(), msg, MessageType::Error); - } - ui.add_space(10.0); inner_action |= self.render_add_button(ui); diff --git a/src/ui/tokens/burn_tokens_screen.rs b/src/ui/tokens/burn_tokens_screen.rs index 5471e4e8e..ba932dabe 100644 --- a/src/ui/tokens/burn_tokens_screen.rs +++ b/src/ui/tokens/burn_tokens_screen.rs @@ -110,9 +110,7 @@ impl BurnTokensScreen { ) .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 diff --git a/src/ui/tokens/claim_tokens_screen.rs b/src/ui/tokens/claim_tokens_screen.rs index 3fe5a61aa..350165316 100644 --- a/src/ui/tokens/claim_tokens_screen.rs +++ b/src/ui/tokens/claim_tokens_screen.rs @@ -89,7 +89,21 @@ impl ClaimTokensScreen { }) .into_iter() .find(|id| id.identity.id() == identity_token_basic_info.identity_id) - .expect("Identity must exist in local store after successful navigation"); + .unwrap_or_else(|| { + MessageBanner::set_global( + app_context.egui_ctx(), + "Identity not found in local store", + MessageType::Error, + ); + // Return first available identity as fallback for degraded state. + // The error banner informs the user; the screen may not function correctly. + app_context + .load_local_qualified_identities() + .unwrap_or_default() + .into_iter() + .next() + .expect("At least one identity must exist to reach this screen") + }); let identity_clone = identity.identity.clone(); let mut possible_key = identity_clone.get_first_public_key_matching( diff --git a/src/ui/tokens/destroy_frozen_funds_screen.rs b/src/ui/tokens/destroy_frozen_funds_screen.rs index 3d068b387..9fc522de3 100644 --- a/src/ui/tokens/destroy_frozen_funds_screen.rs +++ b/src/ui/tokens/destroy_frozen_funds_screen.rs @@ -106,9 +106,7 @@ impl DestroyFrozenFundsScreen { ) .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 diff --git a/src/ui/tokens/freeze_tokens_screen.rs b/src/ui/tokens/freeze_tokens_screen.rs index 61995ae25..70bde7cb2 100644 --- a/src/ui/tokens/freeze_tokens_screen.rs +++ b/src/ui/tokens/freeze_tokens_screen.rs @@ -106,9 +106,7 @@ impl FreezeTokensScreen { ) .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 diff --git a/src/ui/tokens/mint_tokens_screen.rs b/src/ui/tokens/mint_tokens_screen.rs index 542c3c7c2..15c8ef475 100644 --- a/src/ui/tokens/mint_tokens_screen.rs +++ b/src/ui/tokens/mint_tokens_screen.rs @@ -108,9 +108,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 diff --git a/src/ui/tokens/mod.rs b/src/ui/tokens/mod.rs index 5b5c9cd00..701553f0c 100644 --- a/src/ui/tokens/mod.rs +++ b/src/ui/tokens/mod.rs @@ -19,6 +19,14 @@ use crate::ui::MessageType; use crate::ui::components::MessageBanner; use dash_sdk::platform::IdentityPublicKey; +/// 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 diff --git a/src/ui/tokens/pause_tokens_screen.rs b/src/ui/tokens/pause_tokens_screen.rs index 606f76e3b..2441dc87e 100644 --- a/src/ui/tokens/pause_tokens_screen.rs +++ b/src/ui/tokens/pause_tokens_screen.rs @@ -89,9 +89,7 @@ impl PauseTokensScreen { ) .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 diff --git a/src/ui/tokens/resume_tokens_screen.rs b/src/ui/tokens/resume_tokens_screen.rs index f198b1689..22fcb5874 100644 --- a/src/ui/tokens/resume_tokens_screen.rs +++ b/src/ui/tokens/resume_tokens_screen.rs @@ -88,9 +88,7 @@ impl ResumeTokensScreen { ) .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 diff --git a/src/ui/tokens/set_token_price_screen.rs b/src/ui/tokens/set_token_price_screen.rs index bb525f9a5..e8e5322b1 100644 --- a/src/ui/tokens/set_token_price_screen.rs +++ b/src/ui/tokens/set_token_price_screen.rs @@ -172,9 +172,7 @@ impl SetTokenPriceScreen { false, ); - 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 diff --git a/src/ui/tokens/transfer_tokens_screen.rs b/src/ui/tokens/transfer_tokens_screen.rs index ce0e02e85..bc88c5c5f 100644 --- a/src/ui/tokens/transfer_tokens_screen.rs +++ b/src/ui/tokens/transfer_tokens_screen.rs @@ -87,8 +87,19 @@ impl TransferTokensScreen { let identity = known_identities .iter() .find(|identity| identity.identity.id() == identity_token_balance.identity_id) - .expect("Identity must exist in local store after successful navigation") - .clone(); + .cloned() + .unwrap_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() + .expect("At least one identity must exist to reach this screen") + }); let max_amount = Amount::from(&identity_token_balance); let identity_clone = identity.identity.clone(); let selected_key = identity_clone.get_first_public_key_matching( diff --git a/src/ui/tokens/unfreeze_tokens_screen.rs b/src/ui/tokens/unfreeze_tokens_screen.rs index d90c78159..f9059f357 100644 --- a/src/ui/tokens/unfreeze_tokens_screen.rs +++ b/src/ui/tokens/unfreeze_tokens_screen.rs @@ -111,9 +111,7 @@ impl UnfreezeTokensScreen { ) .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 diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index 48d63be39..9f5e4ac4a 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -639,7 +639,7 @@ impl WalletsBalancesScreen { .selected_account .as_ref() .is_some_and(|(category, index)| { - *category == AccountCategory::Bip44 && index.unwrap_or(0) == 0 + *category == AccountCategory::Bip44 && *index == Some(0) }); if wallet_is_open && is_main_account { From 9bb5c3faac2b0fb8738913cd7afa509ecbd7ac44 Mon Sep 17 00:00:00 2001 From: Paul DeLucia <69597248+pauldelucia@users.noreply.github.com> Date: Fri, 27 Feb 2026 22:29:59 +0700 Subject: [PATCH 38/46] fix: validate token description length before sending to Platform (#530) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: validate token description length before sending to Platform Descriptions must be either empty or 3-100 characters long. Co-Authored-By: Claude Opus 4.5 * fix(ui): validate token description by char count, not byte length String::len() counts UTF-8 bytes, causing multi-byte characters (CJK, emoji) to be miscounted against the 3–100 limit. Switch to chars().count() and update all UI labels to surface the minimum. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.5 Co-authored-by: Lukasz Klimek <842586+lklimek@users.noreply.github.com> --- src/ui/tokens/tokens_screen/token_creator.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/ui/tokens/tokens_screen/token_creator.rs b/src/ui/tokens/tokens_screen/token_creator.rs index c110e3f86..6e366a64c 100644 --- a/src/ui/tokens/tokens_screen/token_creator.rs +++ b/src/ui/tokens/tokens_screen/token_creator.rs @@ -284,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() ); } }); @@ -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(); }); @@ -1080,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 From d1dbebd2558f613e24aa9dc9cf2960ca7b07913a Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 27 Feb 2026 16:29:22 +0100 Subject: [PATCH 39/46] refactor(ui): consolidate banner extension traits into message_banner Move BannerHandleExt and ResultBannerExt from banner_ext.rs into message_banner.rs where they belong. Merge take_and_clear() into OptionBannerExt trait (impl for Option) alongside or_show_error() for Option. Remove the separate banner_ext module and Clearable helper trait for simplicity. Apply review findings: DRY patterns (take_and_clear, or_show_error, load_identities_with_banner), fix .expect() panics in constructors, restore known_identities in refresh(), narrow pub field visibility, add ScreenLike doc comments, and update CLAUDE.md conventions. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 2 +- src/app.rs | 9 ++- src/ui/components/message_banner.rs | 71 +++++++++++++++++-- src/ui/components/mod.rs | 5 +- src/ui/components/wallet_unlock.rs | 3 +- .../document_action_screen.rs | 18 ++--- .../register_contract_screen.rs | 15 ++-- .../update_contract_screen.rs | 15 ++-- src/ui/dashpay/add_contact_screen.rs | 6 +- src/ui/dashpay/contact_details.rs | 2 +- src/ui/dashpay/contact_info_editor.rs | 8 +-- src/ui/dashpay/contact_requests.rs | 29 +++----- src/ui/dashpay/profile_screen.rs | 18 ++--- src/ui/dashpay/qr_code_generator.rs | 19 ++--- src/ui/dashpay/qr_scanner.rs | 3 +- src/ui/dpns/dpns_contested_names_screen.rs | 26 ++----- .../add_existing_identity_screen.rs | 21 ++---- src/ui/identities/identities_screen.rs | 10 +-- src/ui/identities/keys/add_key_screen.rs | 34 ++++----- .../identities/register_dpns_name_screen.rs | 33 +++------ src/ui/identities/transfer_screen.rs | 6 +- src/ui/identities/withdraw_screen.rs | 18 ++--- src/ui/mod.rs | 13 ++++ src/ui/tokens/burn_tokens_screen.rs | 17 ++--- src/ui/tokens/claim_tokens_screen.rs | 57 ++++++--------- src/ui/tokens/destroy_frozen_funds_screen.rs | 28 +++----- src/ui/tokens/direct_token_purchase_screen.rs | 13 ++-- src/ui/tokens/freeze_tokens_screen.rs | 26 ++----- src/ui/tokens/mint_tokens_screen.rs | 28 +++----- src/ui/tokens/mod.rs | 12 +++- src/ui/tokens/pause_tokens_screen.rs | 15 ++-- src/ui/tokens/resume_tokens_screen.rs | 15 ++-- src/ui/tokens/set_token_price_screen.rs | 17 ++--- src/ui/tokens/transfer_tokens_screen.rs | 51 +++++-------- src/ui/tokens/unfreeze_tokens_screen.rs | 26 ++----- src/ui/tokens/update_token_config.rs | 43 ++++++----- src/ui/tools/address_balance_screen.rs | 2 +- src/ui/tools/contract_visualizer_screen.rs | 3 +- src/ui/tools/document_visualizer_screen.rs | 3 +- src/ui/wallets/send_screen.rs | 46 +++++------- 40 files changed, 345 insertions(+), 441 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bbd35f03a..3739c7124 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -175,7 +175,7 @@ response.inner.update(&mut self.amount); 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), clear the handle (`self.refresh_banner = None`) to dismiss the progress banner. AppState handles displaying the actual result banner. +**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 `BannerHandleExt`). 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/src/app.rs b/src/app.rs index 7eec49044..791809c70 100644 --- a/src/app.rs +++ b/src/app.rs @@ -875,8 +875,9 @@ impl AppState { self.chosen_network = network; let app_context = self.current_app_context().clone(); - // Clear stale banners from the previous network context so error/success - // messages don't leak across network boundaries. + // 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() { @@ -1000,6 +1001,10 @@ impl App for AppState { self.visible_screen_mut().refresh(); } 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); diff --git a/src/ui/components/message_banner.rs b/src/ui/components/message_banner.rs index f48a7b665..1ba8b6ec2 100644 --- a/src/ui/components/message_banner.rs +++ b/src/ui/components/message_banner.rs @@ -325,8 +325,8 @@ impl MessageBanner { if banners.len() > MAX_BANNERS { let evicted = banners.remove(0); warn!( - "Banner evicted (capacity {}): {:?} '{}'", - MAX_BANNERS, evicted.message_type, evicted.text + "Banner evicted (capacity {}): {:?}", + MAX_BANNERS, evicted.message_type, ); } set_banners(ctx, banners); @@ -347,6 +347,11 @@ impl MessageBanner { /// 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, @@ -378,8 +383,8 @@ impl MessageBanner { if banners.len() > MAX_BANNERS { let evicted = banners.remove(0); warn!( - "Banner evicted (capacity {}): {:?} '{}'", - MAX_BANNERS, evicted.message_type, evicted.text + "Banner evicted (capacity {}): {:?}", + MAX_BANNERS, evicted.message_type, ); } } @@ -695,3 +700,61 @@ 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` — error display and banner cleanup. +/// +/// ```ignore +/// let identity = self.identities.first() +/// .or_show_error(ctx, "No identities loaded"); +/// +/// self.refresh_banner.take_and_clear(); +/// ``` +pub trait OptionBannerExt { + /// 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, err: impl fmt::Display) -> Self; + + /// Takes the handle (leaving `None`) and clears the associated banner. + fn take_and_clear(&mut self); +} + +impl OptionBannerExt for Option { + fn or_show_error(self, ctx: &egui::Context, err: impl fmt::Display) -> Self { + if self.is_none() { + MessageBanner::set_global(ctx, err, MessageType::Error); + } + self + } + + 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..0bf874de1 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, + ResultBannerExt, +}; diff --git a/src/ui/components/wallet_unlock.rs b/src/ui/components/wallet_unlock.rs index e2f3b2f78..43aa7f5ad 100644 --- a/src/ui/components/wallet_unlock.rs +++ b/src/ui/components/wallet_unlock.rs @@ -117,7 +117,8 @@ pub trait ScreenWithWalletUnlock { } else { "Incorrect Password".to_string() }; - MessageBanner::set_global(ui.ctx(), &error_msg, MessageType::Error); + MessageBanner::set_global(ui.ctx(), &error_msg, MessageType::Error) + .with_auto_dismiss(std::time::Duration::from_secs(10)); } } // Clear the password field after submission diff --git a/src/ui/contracts_documents/document_action_screen.rs b/src/ui/contracts_documents/document_action_screen.rs index a10ba8e8b..3d0dbb26e 100644 --- a/src/ui/contracts_documents/document_action_screen.rs +++ b/src/ui/contracts_documents/document_action_screen.rs @@ -11,12 +11,12 @@ use crate::ui::MessageType; use crate::ui::ScreenLike; use crate::ui::components::identity_selector::IdentitySelector; use crate::ui::components::left_panel::add_left_panel; -use crate::ui::components::message_banner::MessageBanner; use crate::ui::components::styled::{island_central_panel, styled_text_edit_singleline}; 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, @@ -267,14 +267,8 @@ impl DocumentActionScreen { // Update wallet self.wallet = get_selected_wallet(identity, Some(&self.app_context), None) - .unwrap_or_else(|e| { - MessageBanner::set_global( - self.app_context.egui_ctx(), - &e, - MessageType::Error, - ); - None - }); + .or_show_error(self.app_context.egui_ctx()) + .unwrap_or(None); } else { self.selected_key = None; self.wallet = None; @@ -1787,10 +1781,8 @@ impl DocumentActionScreen { if let Some(selected_identity) = &self.selected_identity { self.wallet = get_selected_wallet(selected_identity, Some(&self.app_context), None) - .unwrap_or_else(|e| { - MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); - 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) { diff --git a/src/ui/contracts_documents/register_contract_screen.rs b/src/ui/contracts_documents/register_contract_screen.rs index f99e76861..dbf9920f6 100644 --- a/src/ui/contracts_documents/register_contract_screen.rs +++ b/src/ui/contracts_documents/register_contract_screen.rs @@ -6,7 +6,6 @@ use crate::context::AppContext; 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::identity_selector::IdentitySelector; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; @@ -14,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; @@ -65,10 +65,9 @@ impl RegisterDataContractScreen { let selected_qualified_identity = qualified_identities.first().cloned(); let selected_wallet = if let Some(ref identity) = selected_qualified_identity { - get_selected_wallet(identity, Some(app_context), None).unwrap_or_else(|e| { - MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error); - None - }) + get_selected_wallet(identity, Some(app_context), None) + .or_show_error(app_context.egui_ctx()) + .unwrap_or(None) } else { None }; @@ -482,10 +481,8 @@ impl ScreenLike for RegisterDataContractScreen { Some(&self.app_context), None, ) - .unwrap_or_else(|e| { - MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); - None - }); + .or_show_error(ui.ctx()) + .unwrap_or(None); // Re-parse contract with new owner ID self.parse_contract(); diff --git a/src/ui/contracts_documents/update_contract_screen.rs b/src/ui/contracts_documents/update_contract_screen.rs index e6e4fc81c..6605e632c 100644 --- a/src/ui/contracts_documents/update_contract_screen.rs +++ b/src/ui/contracts_documents/update_contract_screen.rs @@ -7,7 +7,6 @@ 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::components::MessageBanner; use crate::ui::components::identity_selector::IdentitySelector; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; @@ -15,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; @@ -68,10 +68,9 @@ impl UpdateDataContractScreen { let selected_qualified_identity = qualified_identities.first().cloned(); let selected_wallet = if let Some(ref identity) = selected_qualified_identity { - get_selected_wallet(identity, Some(app_context), None).unwrap_or_else(|e| { - MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error); - None - }) + get_selected_wallet(identity, Some(app_context), None) + .or_show_error(app_context.egui_ctx()) + .unwrap_or(None) } else { None }; @@ -498,10 +497,8 @@ impl ScreenLike for UpdateDataContractScreen { // Update wallet self.selected_wallet = get_selected_wallet(identity, Some(&self.app_context), None) - .unwrap_or_else(|e| { - MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); - None - }); + .or_show_error(ui.ctx()) + .unwrap_or(None); // Re-parse contract with new owner ID self.parse_contract(); diff --git a/src/ui/dashpay/add_contact_screen.rs b/src/ui/dashpay/add_contact_screen.rs index 8315b5541..9554786ee 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; @@ -287,6 +288,7 @@ impl ScreenLike for AddContactScreen { if self.selected_wallet.is_none() { self.selected_wallet = get_selected_wallet(identity, Some(&self.app_context), None) + .or_show_error(self.app_context.egui_ctx()) .unwrap_or(None); } } else { @@ -618,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") diff --git a/src/ui/dashpay/contact_details.rs b/src/ui/dashpay/contact_details.rs index 32394c310..68baa3d90 100644 --- a/src/ui/dashpay/contact_details.rs +++ b/src/ui/dashpay/contact_details.rs @@ -3,10 +3,10 @@ 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; -use crate::ui::components::message_banner::MessageBanner; use crate::ui::components::styled::island_central_panel; use crate::ui::components::top_panel::add_top_panel; use crate::ui::dashpay::DashPaySubscreen; diff --git a/src/ui/dashpay/contact_info_editor.rs b/src/ui/dashpay/contact_info_editor.rs index c8ca4e577..d755f9daa 100644 --- a/src/ui/dashpay/contact_info_editor.rs +++ b/src/ui/dashpay/contact_info_editor.rs @@ -4,7 +4,6 @@ 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::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; @@ -13,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; @@ -52,10 +52,8 @@ impl ContactInfoEditorScreen { ) -> Self { // Get wallet for the identity let selected_wallet = get_selected_wallet(&identity, Some(&app_context), None) - .unwrap_or_else(|e| { - MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error); - None - }); + .or_show_error(app_context.egui_ctx()) + .unwrap_or(None); Self { app_context, diff --git a/src/ui/dashpay/contact_requests.rs b/src/ui/dashpay/contact_requests.rs index 4be3d0f0c..de65195a0 100644 --- a/src/ui/dashpay/contact_requests.rs +++ b/src/ui/dashpay/contact_requests.rs @@ -5,13 +5,13 @@ 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::MessageBanner; use crate::ui::components::component_trait::Component; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; 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; @@ -103,10 +103,9 @@ impl ContactRequests { // Get wallet for the selected identity new_self.selected_wallet = - get_selected_wallet(&identities[0], Some(&app_context), None).unwrap_or_else(|e| { - MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error); - None - }); + 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(); @@ -133,14 +132,8 @@ impl ContactRequests { // Update wallet for the newly selected identity self.selected_wallet = get_selected_wallet(id, Some(&self.app_context), None) - .unwrap_or_else(|e| { - MessageBanner::set_global( - self.app_context.egui_ctx(), - &e, - MessageType::Error, - ); - None - }); + .or_show_error(self.app_context.egui_ctx()) + .unwrap_or(None); } else { self.selected_identity_string.clear(); self.selected_wallet = None; @@ -585,14 +578,8 @@ impl ContactRequests { if let Some(identity) = &self.selected_identity { self.selected_wallet = get_selected_wallet(identity, Some(&self.app_context), None) - .unwrap_or_else(|e| { - MessageBanner::set_global( - self.app_context.egui_ctx(), - &e, - MessageType::Error, - ); - None - }); + .or_show_error(self.app_context.egui_ctx()) + .unwrap_or(None); } else { self.selected_wallet = None; } diff --git a/src/ui/dashpay/profile_screen.rs b/src/ui/dashpay/profile_screen.rs index bb1ae9c29..bcf184788 100644 --- a/src/ui/dashpay/profile_screen.rs +++ b/src/ui/dashpay/profile_screen.rs @@ -6,7 +6,6 @@ use crate::model::fee_estimation::format_credits_as_dash; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; use crate::ui::MessageType; -use crate::ui::components::MessageBanner; use crate::ui::components::component_trait::Component; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::identity_selector::IdentitySelector; @@ -14,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; @@ -203,10 +203,8 @@ impl ProfileScreen { // Get wallet for the selected identity new_self.selected_wallet = get_selected_wallet(&identities[selected_idx], Some(&app_context), None) - .unwrap_or_else(|e| { - MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error); - None - }); + .or_show_error(app_context.egui_ctx()) + .unwrap_or(None); // Load profile from database for this identity new_self.load_profile_from_database(); @@ -634,14 +632,8 @@ impl ProfileScreen { if let Some(identity) = &self.selected_identity { self.selected_wallet = get_selected_wallet(identity, Some(&self.app_context), None) - .unwrap_or_else(|e| { - MessageBanner::set_global( - self.app_context.egui_ctx(), - &e, - MessageType::Error, - ); - None - }); + .or_show_error(self.app_context.egui_ctx()) + .unwrap_or(None); } else { self.selected_wallet = None; } diff --git a/src/ui/dashpay/qr_code_generator.rs b/src/ui/dashpay/qr_code_generator.rs index e3f904fd5..fd721de36 100644 --- a/src/ui/dashpay/qr_code_generator.rs +++ b/src/ui/dashpay/qr_code_generator.rs @@ -3,7 +3,6 @@ use crate::backend_task::dashpay::auto_accept_proof::generate_auto_accept_proof; use crate::context::AppContext; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; -use crate::ui::components::MessageBanner; 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; @@ -13,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; @@ -77,10 +77,9 @@ impl QRCodeGeneratorScreen { // Get wallet for the selected identity new_self.selected_wallet = - get_selected_wallet(&identities[0], Some(&app_context), None).unwrap_or_else(|e| { - MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error); - None - }); + get_selected_wallet(&identities[0], Some(&app_context), None) + .or_show_error(app_context.egui_ctx()) + .unwrap_or(None); } new_self @@ -209,14 +208,8 @@ impl QRCodeGeneratorScreen { Some(&self.app_context), None, ) - .unwrap_or_else(|e| { - MessageBanner::set_global( - self.app_context.egui_ctx(), - &e, - MessageType::Error, - ); - None - }); + .or_show_error(self.app_context.egui_ctx()) + .unwrap_or(None); } else { self.selected_wallet = None; } diff --git a/src/ui/dashpay/qr_scanner.rs b/src/ui/dashpay/qr_scanner.rs index 3fc72b5a4..99edfe3f0 100644 --- a/src/ui/dashpay/qr_scanner.rs +++ b/src/ui/dashpay/qr_scanner.rs @@ -5,7 +5,6 @@ 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::MessageBanner; use crate::ui::components::dashpay_subscreen_chooser_panel::add_dashpay_subscreen_chooser_panel; use crate::ui::components::identity_selector::IdentitySelector; use crate::ui::components::left_panel::add_left_panel; @@ -14,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::dashpay::dashpay_screen::DashPaySubscreen; use crate::ui::identities::get_selected_wallet; use crate::ui::{MessageType, RootScreenType, ScreenLike}; @@ -189,6 +189,7 @@ impl QRScannerScreen { Some(&self.app_context), None, ) + .or_show_error(self.app_context.egui_ctx()) .unwrap_or(None); } else { self.selected_wallet = None; diff --git a/src/ui/dpns/dpns_contested_names_screen.rs b/src/ui/dpns/dpns_contested_names_screen.rs index 8d590e589..2e283c7fe 100644 --- a/src/ui/dpns/dpns_contested_names_screen.rs +++ b/src/ui/dpns/dpns_contested_names_screen.rs @@ -23,7 +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}; +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}; @@ -1833,12 +1833,8 @@ impl ScreenLike for DPNSScreen { 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) { - if let Some(h) = self.refresh_banner.take() { - h.clear(); - } - if let Some(h) = self.vote_banner.take() { - h.clear(); - } + 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; @@ -1919,9 +1915,7 @@ impl ScreenLike for DPNSScreen { } BackendTaskSuccessResult::RefreshedDpnsContests | BackendTaskSuccessResult::RefreshedOwnedDpnsNames => { - if let Some(h) = self.refresh_banner.take() { - h.clear(); - } + self.refresh_banner.take_and_clear(); self.refreshing_status = RefreshingStatus::NotRefreshing; } _ => {} @@ -2109,9 +2103,7 @@ impl ScreenLike for DPNSScreen { AppAction::BackendTask(BackendTask::ContestedResourceTask( ContestedResourceTask::QueryDPNSContests, )) => { - if let Some(h) = self.refresh_banner.take() { - h.clear(); - } + self.refresh_banner.take_and_clear(); let handle = MessageBanner::set_global( ctx, "Refreshing contested names...", @@ -2125,9 +2117,7 @@ impl ScreenLike for DPNSScreen { AppAction::BackendTask(BackendTask::IdentityTask( IdentityTask::RefreshLoadedIdentitiesOwnedDPNSNames, )) => { - if let Some(h) = self.refresh_banner.take() { - h.clear(); - } + self.refresh_banner.take_and_clear(); let handle = MessageBanner::set_global( ctx, "Refreshing contested names...", @@ -2138,9 +2128,7 @@ impl ScreenLike for DPNSScreen { self.refreshing_status = RefreshingStatus::Refreshing; } AppAction::SetMainScreen(_) => { - if let Some(h) = self.refresh_banner.take() { - h.clear(); - } + 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 6711f7d78..9da0df439 100644 --- a/src/ui/identities/add_existing_identity_screen.rs +++ b/src/ui/identities/add_existing_identity_screen.rs @@ -11,7 +11,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}; +use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt}; use crate::ui::theme::DashColors; use crate::ui::{MessageType, ScreenLike}; use bip39::rand::{prelude::IteratorRandom, thread_rng}; @@ -86,6 +86,7 @@ enum WalletIdentitySearchMode { pub enum AddIdentityStatus { NotStarted, WaitingForResult, + Error, Complete, } @@ -968,19 +969,15 @@ impl ScreenLike for AddExistingIdentityScreen { // Side-effects only: update status and progress tracking. match message_type { MessageType::Error => { - if let Some(handle) = self.refresh_banner.take() { - handle.clear(); - } - self.add_identity_status = AddIdentityStatus::NotStarted; + 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") { - if let Some(handle) = self.refresh_banner.take() { - handle.clear(); - } + self.refresh_banner.take_and_clear(); self.success_message = Some(message.to_string()); self.add_identity_status = AddIdentityStatus::Complete; } else { @@ -997,18 +994,14 @@ impl ScreenLike for AddExistingIdentityScreen { fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { match backend_task_success_result { BackendTaskSuccessResult::LoadedIdentity(_) => { - if let Some(handle) = self.refresh_banner.take() { - handle.clear(); - } + self.refresh_banner.take_and_clear(); self.success_message = Some("Successfully loaded identity.".to_string()); self.add_identity_status = AddIdentityStatus::Complete; } 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") { - if let Some(handle) = self.refresh_banner.take() { - handle.clear(); - } + self.refresh_banner.take_and_clear(); self.success_message = Some(msg); self.add_identity_status = AddIdentityStatus::Complete; } else { diff --git a/src/ui/identities/identities_screen.rs b/src/ui/identities/identities_screen.rs index ab9040e82..e3e473eca 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::{ @@ -1125,9 +1125,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 +1227,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 6e942c87e..5a6131607 100644 --- a/src/ui/identities/keys/add_key_screen.rs +++ b/src/ui/identities/keys/add_key_screen.rs @@ -12,7 +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}; +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}; @@ -65,11 +65,9 @@ impl AddKeyScreen { KeyType::all_key_types().into(), false, ); - let selected_wallet = - get_selected_wallet(&identity, None, selected_key).unwrap_or_else(|e| { - MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error); - None - }); + let selected_wallet = get_selected_wallet(&identity, None, selected_key) + .or_show_error(app_context.egui_ctx()) + .unwrap_or(None); Self { identity, @@ -102,11 +100,9 @@ impl AddKeyScreen { KeyType::all_key_types().into(), false, ); - let selected_wallet = - get_selected_wallet(&identity, None, selected_key).unwrap_or_else(|e| { - MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error); - None - }); + 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 @@ -144,11 +140,9 @@ impl AddKeyScreen { KeyType::all_key_types().into(), false, ); - let selected_wallet = - get_selected_wallet(&identity, None, selected_key).unwrap_or_else(|e| { - MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error); - None - }); + 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 @@ -355,9 +349,7 @@ impl ScreenLike for AddKeyScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { // Error/success display is handled by the global MessageBanner. if let MessageType::Error = message_type { - if let Some(handle) = self.refresh_banner.take() { - handle.clear(); - } + self.refresh_banner.take_and_clear(); self.add_key_status = AddKeyStatus::Error; } } @@ -365,9 +357,7 @@ impl ScreenLike for AddKeyScreen { fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { match backend_task_success_result { BackendTaskSuccessResult::AddedKeyToIdentity(fee_result) => { - if let Some(handle) = self.refresh_banner.take() { - handle.clear(); - } + self.refresh_banner.take_and_clear(); self.completed_fee_result = Some(fee_result); self.add_key_status = AddKeyStatus::Complete; } diff --git a/src/ui/identities/register_dpns_name_screen.rs b/src/ui/identities/register_dpns_name_screen.rs index 43e8ad054..376feec3a 100644 --- a/src/ui/identities/register_dpns_name_screen.rs +++ b/src/ui/identities/register_dpns_name_screen.rs @@ -12,7 +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}; +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}; @@ -70,10 +70,9 @@ impl RegisterDpnsNameScreen { let selected_qualified_identity = qualified_identities.first().cloned(); let selected_wallet = if let Some(ref identity) = selected_qualified_identity { - get_selected_wallet(identity, Some(app_context), None).unwrap_or_else(|e| { - MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error); - None - }) + get_selected_wallet(identity, Some(app_context), None) + .or_show_error(app_context.egui_ctx()) + .unwrap_or(None) } else { None }; @@ -162,10 +161,8 @@ impl RegisterDpnsNameScreen { // Update the selected wallet self.selected_wallet = get_selected_wallet(qi, Some(&self.app_context), None) - .unwrap_or_else(|e| { - MessageBanner::set_global(self.app_context.egui_ctx(), &e, MessageType::Error); - 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 @@ -217,14 +214,8 @@ impl RegisterDpnsNameScreen { // Update wallet self.selected_wallet = get_selected_wallet(identity, Some(&self.app_context), None) - .unwrap_or_else(|e| { - MessageBanner::set_global( - self.app_context.egui_ctx(), - &e, - MessageType::Error, - ); - None - }); + .or_show_error(self.app_context.egui_ctx()) + .unwrap_or(None); } else { self.selected_key = None; self.selected_wallet = None; @@ -305,9 +296,7 @@ impl ScreenLike for RegisterDpnsNameScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. if let MessageType::Error = message_type { - if let Some(handle) = self.refresh_banner.take() { - handle.clear(); - } + self.refresh_banner.take_and_clear(); self.register_dpns_name_status = RegisterDpnsNameStatus::Error; } } @@ -316,9 +305,7 @@ impl ScreenLike for RegisterDpnsNameScreen { if let BackendTaskSuccessResult::RegisteredDpnsName(fee_result) = backend_task_success_result { - if let Some(handle) = self.refresh_banner.take() { - handle.clear(); - } + self.refresh_banner.take_and_clear(); self.completed_fee_result = Some(fee_result); self.register_dpns_name_status = RegisterDpnsNameStatus::Complete; } diff --git a/src/ui/identities/transfer_screen.rs b/src/ui/identities/transfer_screen.rs index ce731578d..a1134046c 100644 --- a/src/ui/identities/transfer_screen.rs +++ b/src/ui/identities/transfer_screen.rs @@ -13,7 +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}; +use crate::ui::components::{BannerHandle, MessageBanner, ResultBannerExt}; use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; use crate::ui::{MessageType, Screen, ScreenLike}; use dash_sdk::dashcore_rpc::dashcore::Address; @@ -81,7 +81,8 @@ 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(); @@ -542,6 +543,7 @@ impl ScreenLike for TransferScreen { self.identity = refreshed.clone(); self.max_amount = self.identity.identity.balance(); } + self.known_identities = identities; } /// Renders the UI components for the withdrawal screen diff --git a/src/ui/identities/withdraw_screen.rs b/src/ui/identities/withdraw_screen.rs index 64d67a72c..b33ac65ff 100644 --- a/src/ui/identities/withdraw_screen.rs +++ b/src/ui/identities/withdraw_screen.rs @@ -15,7 +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}; +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; @@ -73,11 +73,9 @@ impl WithdrawalScreen { KeyType::all_key_types().into(), false, ); - let selected_wallet = - get_selected_wallet(&identity, None, selected_key).unwrap_or_else(|e| { - MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error); - None - }); + 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(), @@ -331,9 +329,7 @@ impl ScreenLike for WithdrawalScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. if let MessageType::Error = message_type { - if let Some(handle) = self.refresh_banner.take() { - handle.clear(); - } + self.refresh_banner.take_and_clear(); self.withdraw_from_identity_status = WithdrawFromIdentityStatus::Error; } } @@ -342,9 +338,7 @@ impl ScreenLike for WithdrawalScreen { if let BackendTaskSuccessResult::WithdrewFromIdentity(fee_result) = backend_task_success_result { - if let Some(handle) = self.refresh_banner.take() { - handle.clear(); - } + self.refresh_banner.take_and_clear(); self.completed_fee_result = Some(fee_result); self.withdraw_from_identity_status = WithdrawFromIdentityStatus::Complete; } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 5c56c635b..08e312893 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -840,7 +840,20 @@ 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) {} + + /// 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/tokens/burn_tokens_screen.rs b/src/ui/tokens/burn_tokens_screen.rs index ba932dabe..e19dba11a 100644 --- a/src/ui/tokens/burn_tokens_screen.rs +++ b/src/ui/tokens/burn_tokens_screen.rs @@ -6,7 +6,7 @@ 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, 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; @@ -64,8 +64,8 @@ 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, @@ -268,7 +268,8 @@ impl BurnTokensScreen { self.confirmation_dialog = None; // Validate signing key before transitioning to waiting state - let Some(signing_key) = validate_signing_key(&self.app_context, &self.selected_key) + let Some(signing_key) = + validate_signing_key(&self.app_context, self.selected_key.as_ref()) else { return AppAction::None; }; @@ -349,18 +350,14 @@ impl ScreenLike for BurnTokensScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. if let MessageType::Error = message_type { - if let Some(h) = self.refresh_banner.take() { - h.clear(); - } + 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 { - if let Some(h) = self.refresh_banner.take() { - h.clear(); - } + self.refresh_banner.take_and_clear(); self.completed_fee_result = Some(fee_result); self.status = BurnTokensStatus::Complete; } diff --git a/src/ui/tokens/claim_tokens_screen.rs b/src/ui/tokens/claim_tokens_screen.rs index 350165316..fbbacaa85 100644 --- a/src/ui/tokens/claim_tokens_screen.rs +++ b/src/ui/tokens/claim_tokens_screen.rs @@ -5,7 +5,7 @@ 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}; +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}; @@ -51,11 +51,11 @@ pub enum ClaimTokensStatus { } pub struct ClaimTokensScreen { - pub identity: QualifiedIdentity, + identity: QualifiedIdentity, 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, @@ -77,33 +77,25 @@ impl ClaimTokensScreen { token_configuration: TokenConfiguration, app_context: &Arc, ) -> Self { - let identity = app_context + let known_identities = app_context .load_local_qualified_identities() - .unwrap_or_else(|e| { - MessageBanner::set_global( - app_context.egui_ctx(), - format!("Failed to load identities: {e}"), - MessageType::Error, - ); - vec![] - }) - .into_iter() + .or_show_error(app_context.egui_ctx()) + .unwrap_or_default(); + + let identity = known_identities + .iter() .find(|id| id.identity.id() == identity_token_basic_info.identity_id) - .unwrap_or_else(|| { + .cloned() + .or_else(|| { MessageBanner::set_global( app_context.egui_ctx(), "Identity not found in local store", MessageType::Error, ); - // Return first available identity as fallback for degraded state. - // The error banner informs the user; the screen may not function correctly. - app_context - .load_local_qualified_identities() - .unwrap_or_default() - .into_iter() - .next() - .expect("At least one identity must exist to reach this screen") - }); + // Fallback to first available identity for degraded state. + known_identities.first().cloned() + }) + .expect("UI prevents opening this screen without loaded identities"); let identity_clone = identity.identity.clone(); let mut possible_key = identity_clone.get_first_public_key_matching( @@ -122,11 +114,9 @@ impl ClaimTokensScreen { ); } - let selected_wallet = - get_selected_wallet(&identity, None, possible_key).unwrap_or_else(|e| { - MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error); - None - }); + let selected_wallet = get_selected_wallet(&identity, None, possible_key) + .or_show_error(app_context.egui_ctx()) + .unwrap_or(None); let distribution_type = match ( token_configuration @@ -231,7 +221,8 @@ impl ClaimTokensScreen { self.confirmation_dialog = None; // Validate signing key before transitioning to waiting state - let Some(signing_key) = validate_signing_key(&self.app_context, &self.selected_key) + let Some(signing_key) = + validate_signing_key(&self.app_context, self.selected_key.as_ref()) else { return AppAction::None; }; @@ -282,18 +273,14 @@ impl ScreenLike for ClaimTokensScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. if let MessageType::Error = message_type { - if let Some(h) = self.refresh_banner.take() { - h.clear(); - } + 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 { - if let Some(h) = self.refresh_banner.take() { - h.clear(); - } + self.refresh_banner.take_and_clear(); self.completed_fee_result = Some(fee_result); self.status = ClaimTokensStatus::Complete; } diff --git a/src/ui/tokens/destroy_frozen_funds_screen.rs b/src/ui/tokens/destroy_frozen_funds_screen.rs index 9fc522de3..ad78c96b4 100644 --- a/src/ui/tokens/destroy_frozen_funds_screen.rs +++ b/src/ui/tokens/destroy_frozen_funds_screen.rs @@ -16,7 +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::{BannerHandle, MessageBanner}; +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; @@ -52,7 +52,7 @@ pub enum DestroyFrozenFundsStatus { /// 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, @@ -74,7 +74,7 @@ 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, @@ -194,16 +194,7 @@ impl DestroyFrozenFundsScreen { None }); - let all_identities = app_context - .load_local_qualified_identities() - .unwrap_or_else(|e| { - MessageBanner::set_global( - app_context.egui_ctx(), - format!("Failed to load identities: {e}"), - MessageType::Error, - ); - vec![] - }); + let all_identities = super::load_identities_with_banner(app_context); Self { identity: identity_token_info.identity.clone(), @@ -284,7 +275,8 @@ impl DestroyFrozenFundsScreen { }; // Validate signing key before transitioning to waiting state - let Some(signing_key) = validate_signing_key(&self.app_context, &self.selected_key) else { + let Some(signing_key) = validate_signing_key(&self.app_context, self.selected_key.as_ref()) + else { return AppAction::None; }; @@ -349,9 +341,7 @@ impl ScreenLike for DestroyFrozenFundsScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. if let MessageType::Error = message_type { - if let Some(h) = self.refresh_banner.take() { - h.clear(); - } + self.refresh_banner.take_and_clear(); self.status = DestroyFrozenFundsStatus::Error; } } @@ -360,9 +350,7 @@ impl ScreenLike for DestroyFrozenFundsScreen { if let BackendTaskSuccessResult::DestroyedFrozenFunds(fee_result) = backend_task_success_result { - if let Some(h) = self.refresh_banner.take() { - h.clear(); - } + self.refresh_banner.take_and_clear(); self.completed_fee_result = Some(fee_result); self.status = DestroyFrozenFundsStatus::Complete; } diff --git a/src/ui/tokens/direct_token_purchase_screen.rs b/src/ui/tokens/direct_token_purchase_screen.rs index 9a2b19421..4a32f5ace 100644 --- a/src/ui/tokens/direct_token_purchase_screen.rs +++ b/src/ui/tokens/direct_token_purchase_screen.rs @@ -26,7 +26,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}; +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; @@ -293,7 +293,8 @@ 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) + let Some(signing_key) = + validate_signing_key(&self.app_context, self.selected_key.as_ref()) else { return AppAction::None; }; @@ -367,9 +368,7 @@ impl ScreenLike for PurchaseTokenScreen { } } BackendTaskSuccessResult::PurchasedTokens(fee_result) => { - if let Some(h) = self.refresh_banner.take() { - h.clear(); - } + self.refresh_banner.take_and_clear(); self.completed_fee_result = Some(fee_result); self.status = PurchaseTokensStatus::Complete; } @@ -380,9 +379,7 @@ impl ScreenLike for PurchaseTokenScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. if let MessageType::Error = message_type { - if let Some(h) = self.refresh_banner.take() { - h.clear(); - } + self.refresh_banner.take_and_clear(); self.status = PurchaseTokensStatus::Error; } } diff --git a/src/ui/tokens/freeze_tokens_screen.rs b/src/ui/tokens/freeze_tokens_screen.rs index 70bde7cb2..48619c167 100644 --- a/src/ui/tokens/freeze_tokens_screen.rs +++ b/src/ui/tokens/freeze_tokens_screen.rs @@ -16,7 +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::{BannerHandle, MessageBanner}; +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; @@ -51,7 +51,7 @@ pub enum FreezeTokensStatus { /// 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, @@ -84,16 +84,7 @@ pub struct FreezeTokensScreen { impl FreezeTokensScreen { pub fn new(identity_token_info: IdentityTokenInfo, app_context: &Arc) -> Self { - let known_identities = app_context - .load_local_qualified_identities() - .unwrap_or_else(|e| { - MessageBanner::set_global( - app_context.egui_ctx(), - format!("Failed to load identities: {e}"), - MessageType::Error, - ); - vec![] - }); + let known_identities = super::load_identities_with_banner(app_context); let possible_key = identity_token_info .identity @@ -275,7 +266,8 @@ impl FreezeTokensScreen { }; // Validate signing key before transitioning to waiting state - let Some(signing_key) = validate_signing_key(&self.app_context, &self.selected_key) else { + let Some(signing_key) = validate_signing_key(&self.app_context, self.selected_key.as_ref()) + else { return AppAction::None; }; @@ -341,18 +333,14 @@ impl ScreenLike for FreezeTokensScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. if let MessageType::Error = message_type { - if let Some(h) = self.refresh_banner.take() { - h.clear(); - } + 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 { - if let Some(h) = self.refresh_banner.take() { - h.clear(); - } + self.refresh_banner.take_and_clear(); self.completed_fee_result = Some(fee_result); self.status = FreezeTokensStatus::Complete; } diff --git a/src/ui/tokens/mint_tokens_screen.rs b/src/ui/tokens/mint_tokens_screen.rs index 15c8ef475..8fe68fdf5 100644 --- a/src/ui/tokens/mint_tokens_screen.rs +++ b/src/ui/tokens/mint_tokens_screen.rs @@ -18,7 +18,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}; +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; @@ -63,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 @@ -86,16 +86,7 @@ pub struct MintTokensScreen { impl MintTokensScreen { pub fn new(identity_token_info: IdentityTokenInfo, app_context: &Arc) -> Self { - let known_identities = app_context - .load_local_qualified_identities() - .unwrap_or_else(|e| { - MessageBanner::set_global( - app_context.egui_ctx(), - format!("Failed to load identities: {e}"), - MessageType::Error, - ); - vec![] - }); + let known_identities = super::load_identities_with_banner(app_context); let possible_key = identity_token_info .identity @@ -311,7 +302,8 @@ impl MintTokensScreen { }; // Validate signing key before transitioning to waiting state - let Some(signing_key) = validate_signing_key(&self.app_context, &self.selected_key) else { + let Some(signing_key) = validate_signing_key(&self.app_context, self.selected_key.as_ref()) + else { return AppAction::None; }; @@ -375,18 +367,14 @@ impl ScreenLike for MintTokensScreen { 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) { - if let Some(h) = self.refresh_banner.take() { - h.clear(); - } + self.refresh_banner.take_and_clear(); self.status = MintTokensStatus::Error; } } fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { if let BackendTaskSuccessResult::MintedTokens(fee_result) = backend_task_success_result { - if let Some(h) = self.refresh_banner.take() { - h.clear(); - } + self.refresh_banner.take_and_clear(); self.completed_fee_result = Some(fee_result); self.status = MintTokensStatus::Complete; } diff --git a/src/ui/tokens/mod.rs b/src/ui/tokens/mod.rs index 701553f0c..66ee31426 100644 --- a/src/ui/tokens/mod.rs +++ b/src/ui/tokens/mod.rs @@ -15,10 +15,20 @@ 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 @@ -33,7 +43,7 @@ pub fn set_error_banner(app_context: &AppContext, msg: &str) { /// `None` so callers can bail out early with `let Some(key) = ... else { return; }`. pub fn validate_signing_key( app_context: &AppContext, - selected_key: &Option, + selected_key: Option<&IdentityPublicKey>, ) -> Option { match selected_key { Some(key) => Some(key.clone()), diff --git a/src/ui/tokens/pause_tokens_screen.rs b/src/ui/tokens/pause_tokens_screen.rs index 2441dc87e..327840b65 100644 --- a/src/ui/tokens/pause_tokens_screen.rs +++ b/src/ui/tokens/pause_tokens_screen.rs @@ -15,7 +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}; +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; @@ -50,7 +50,7 @@ pub enum PauseTokensStatus { /// 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, @@ -209,7 +209,8 @@ impl PauseTokensScreen { self.confirmation_dialog = None; // Validate signing key before transitioning to waiting state - let Some(signing_key) = validate_signing_key(&self.app_context, &self.selected_key) + let Some(signing_key) = + validate_signing_key(&self.app_context, self.selected_key.as_ref()) else { return AppAction::None; }; @@ -281,18 +282,14 @@ impl ScreenLike for PauseTokensScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. if let MessageType::Error = message_type { - if let Some(h) = self.refresh_banner.take() { - h.clear(); - } + 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 { - if let Some(h) = self.refresh_banner.take() { - h.clear(); - } + self.refresh_banner.take_and_clear(); self.completed_fee_result = Some(fee_result); self.status = PauseTokensStatus::Complete; } diff --git a/src/ui/tokens/resume_tokens_screen.rs b/src/ui/tokens/resume_tokens_screen.rs index 22fcb5874..ebd41cec1 100644 --- a/src/ui/tokens/resume_tokens_screen.rs +++ b/src/ui/tokens/resume_tokens_screen.rs @@ -15,7 +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}; +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; @@ -49,7 +49,7 @@ pub enum ResumeTokensStatus { } pub struct ResumeTokensScreen { - pub identity: QualifiedIdentity, + identity: QualifiedIdentity, pub identity_token_info: IdentityTokenInfo, selected_key: Option, show_advanced_options: bool, @@ -209,7 +209,8 @@ impl ResumeTokensScreen { self.confirmation_dialog = None; // Validate signing key before transitioning to waiting state - let Some(signing_key) = validate_signing_key(&self.app_context, &self.selected_key) + let Some(signing_key) = + validate_signing_key(&self.app_context, self.selected_key.as_ref()) else { return AppAction::None; }; @@ -281,18 +282,14 @@ impl ScreenLike for ResumeTokensScreen { 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) { - if let Some(h) = self.refresh_banner.take() { - h.clear(); - } + 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 { - if let Some(h) = self.refresh_banner.take() { - h.clear(); - } + self.refresh_banner.take_and_clear(); self.completed_fee_result = Some(fee_result); self.status = ResumeTokensStatus::Complete; } diff --git a/src/ui/tokens/set_token_price_screen.rs b/src/ui/tokens/set_token_price_screen.rs index e8e5322b1..9e3c6d9e2 100644 --- a/src/ui/tokens/set_token_price_screen.rs +++ b/src/ui/tokens/set_token_price_screen.rs @@ -17,7 +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}; +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; @@ -93,14 +93,14 @@ 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, /// Basic references @@ -725,7 +725,8 @@ impl SetTokenPriceScreen { }; // Validate signing key before transitioning to waiting state - let Some(signing_key) = validate_signing_key(&self.app_context, &self.selected_key) else { + let Some(signing_key) = validate_signing_key(&self.app_context, self.selected_key.as_ref()) + else { return AppAction::None; }; @@ -828,18 +829,14 @@ impl ScreenLike for SetTokenPriceScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. if let MessageType::Error = message_type { - if let Some(h) = self.refresh_banner.take() { - h.clear(); - } + 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 { - if let Some(h) = self.refresh_banner.take() { - h.clear(); - } + self.refresh_banner.take_and_clear(); self.completed_fee_result = Some(fee_result); self.status = SetTokenPriceStatus::Complete; } diff --git a/src/ui/tokens/transfer_tokens_screen.rs b/src/ui/tokens/transfer_tokens_screen.rs index bc88c5c5f..07dd6caa2 100644 --- a/src/ui/tokens/transfer_tokens_screen.rs +++ b/src/ui/tokens/transfer_tokens_screen.rs @@ -17,7 +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}; +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; @@ -47,15 +47,15 @@ pub enum TransferTokensStatus { } pub struct TransferTokensScreen { - pub identity: QualifiedIdentity, + identity: QualifiedIdentity, 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, @@ -75,31 +75,23 @@ impl TransferTokensScreen { ) -> Self { let known_identities = app_context .load_local_qualified_identities() - .unwrap_or_else(|e| { - MessageBanner::set_global( - app_context.egui_ctx(), - format!("Failed to load identities: {e}"), - MessageType::Error, - ); - vec![] - }); + .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) .cloned() - .unwrap_or_else(|| { + .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() - .expect("At least one identity must exist to reach this screen") - }); + known_identities.first().cloned() + }) + .expect("UI prevents opening this screen without loaded identities"); let max_amount = Amount::from(&identity_token_balance); let identity_clone = identity.identity.clone(); let selected_key = identity_clone.get_first_public_key_matching( @@ -108,11 +100,9 @@ impl TransferTokensScreen { KeyType::all_key_types().into(), false, ); - let selected_wallet = - get_selected_wallet(&identity, None, selected_key).unwrap_or_else(|e| { - MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error); - None - }); + let selected_wallet = get_selected_wallet(&identity, None, selected_key) + .or_show_error(app_context.egui_ctx()) + .unwrap_or(None); let amount = Some(Amount::from(&identity_token_balance).with_value(0)); @@ -244,7 +234,8 @@ impl TransferTokensScreen { }; // Validate signing key before transitioning to waiting state - let Some(signing_key) = validate_signing_key(&self.app_context, &self.selected_key) else { + let Some(signing_key) = validate_signing_key(&self.app_context, self.selected_key.as_ref()) + else { return AppAction::None; }; @@ -298,9 +289,7 @@ impl ScreenLike for TransferTokensScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. if let MessageType::Error = message_type { - if let Some(h) = self.refresh_banner.take() { - h.clear(); - } + self.refresh_banner.take_and_clear(); self.transfer_tokens_status = TransferTokensStatus::Error; } } @@ -308,9 +297,7 @@ impl ScreenLike for TransferTokensScreen { fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { if let BackendTaskSuccessResult::TransferredTokens(fee_result) = backend_task_success_result { - if let Some(h) = self.refresh_banner.take() { - h.clear(); - } + self.refresh_banner.take_and_clear(); self.completed_fee_result = Some(fee_result); self.transfer_tokens_status = TransferTokensStatus::Complete; } diff --git a/src/ui/tokens/unfreeze_tokens_screen.rs b/src/ui/tokens/unfreeze_tokens_screen.rs index f9059f357..6c680f7e4 100644 --- a/src/ui/tokens/unfreeze_tokens_screen.rs +++ b/src/ui/tokens/unfreeze_tokens_screen.rs @@ -16,7 +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::{BannerHandle, MessageBanner}; +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; @@ -52,7 +52,7 @@ pub enum UnfreezeTokensStatus { /// 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, @@ -89,16 +89,7 @@ pub struct UnfreezeTokensScreen { 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() - .unwrap_or_else(|e| { - MessageBanner::set_global( - app_context.egui_ctx(), - format!("Failed to load identities: {e}"), - MessageType::Error, - ); - vec![] - }); + let frozen_identities = super::load_identities_with_banner(app_context); let possible_key = identity_token_info .identity @@ -277,7 +268,8 @@ impl UnfreezeTokensScreen { }; // Validate signing key before transitioning to waiting state - let Some(signing_key) = validate_signing_key(&self.app_context, &self.selected_key) else { + let Some(signing_key) = validate_signing_key(&self.app_context, self.selected_key.as_ref()) + else { return AppAction::None; }; @@ -344,18 +336,14 @@ impl ScreenLike for UnfreezeTokensScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. if let MessageType::Error = message_type { - if let Some(h) = self.refresh_banner.take() { - h.clear(); - } + 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 { - if let Some(h) = self.refresh_banner.take() { - h.clear(); - } + self.refresh_banner.take_and_clear(); self.completed_fee_result = Some(fee_result); self.status = UnfreezeTokensStatus::Complete; } diff --git a/src/ui/tokens/update_token_config.rs b/src/ui/tokens/update_token_config.rs index 817412343..ba0787132 100644 --- a/src/ui/tokens/update_token_config.rs +++ b/src/ui/tokens/update_token_config.rs @@ -6,7 +6,6 @@ use crate::context::AppContext; 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::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; @@ -14,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, 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; @@ -21,7 +21,6 @@ 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; @@ -45,7 +44,7 @@ use std::sync::{Arc, RwLock}; #[derive(Debug, Clone, PartialEq)] pub enum UpdateTokenConfigStatus { NotUpdating, - Updating(DateTime), + Updating, Complete, } @@ -54,8 +53,8 @@ pub struct UpdateTokenConfigScreen { 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, @@ -65,13 +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, // Fee result from completed operation completed_fee_result: Option, + // Banner handle for elapsed time display + refresh_banner: Option, } impl UpdateTokenConfigScreen { @@ -96,10 +97,8 @@ impl UpdateTokenConfigScreen { // Attempt to get an unlocked wallet reference 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 - }); + .or_show_error(app_context.egui_ctx()) + .unwrap_or(None); Self { identity_token_info: identity_token_info.clone(), @@ -123,6 +122,7 @@ impl UpdateTokenConfigScreen { is_unilateral_group_member, group_action_id: None, completed_fee_result: None, + refresh_banner: None, } } @@ -763,9 +763,15 @@ impl UpdateTokenConfigScreen { }; if let Some(signing_key) = - validate_signing_key(&self.app_context, &self.signing_key) + validate_signing_key(&self.app_context, self.signing_key.as_ref()) { - self.update_status = UpdateTokenConfigStatus::Updating(Utc::now()); + self.update_status = UpdateTokenConfigStatus::Updating; + self.refresh_banner = Some(MessageBanner::set_global( + ui.ctx(), + "Updating token configuration...", + MessageType::Info, + )); + self.refresh_banner.as_ref().and_then(|h| h.with_elapsed()); action |= AppAction::BackendTask(BackendTask::TokenTask(Box::new( TokenTask::UpdateTokenConfig { identity_token_info: Box::new(self.identity_token_info.clone()), @@ -919,6 +925,7 @@ impl ScreenLike for UpdateTokenConfigScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. if message_type == MessageType::Error { + self.refresh_banner.take_and_clear(); self.update_status = UpdateTokenConfigStatus::NotUpdating; } } @@ -927,6 +934,7 @@ impl ScreenLike for UpdateTokenConfigScreen { if let BackendTaskSuccessResult::UpdatedTokenConfig(_change_item, fee_result) = backend_task_success_result { + self.refresh_banner.take_and_clear(); self.completed_fee_result = Some(fee_result); self.update_status = UpdateTokenConfigStatus::Complete; } @@ -1074,15 +1082,6 @@ impl ScreenLike for UpdateTokenConfigScreen { action |= self.render_token_config_updater(ui); - // Message display is handled by the global MessageBanner - - 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/tools/address_balance_screen.rs b/src/ui/tools/address_balance_screen.rs index d244ad3a1..387cadd69 100644 --- a/src/ui/tools/address_balance_screen.rs +++ b/src/ui/tools/address_balance_screen.rs @@ -2,8 +2,8 @@ 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::message_banner::MessageBanner; 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; diff --git a/src/ui/tools/contract_visualizer_screen.rs b/src/ui/tools/contract_visualizer_screen.rs index 26eacd2fb..ecc7a6915 100644 --- a/src/ui/tools/contract_visualizer_screen.rs +++ b/src/ui/tools/contract_visualizer_screen.rs @@ -173,7 +173,8 @@ impl ContractVisualizerScreen { impl crate::ui::ScreenLike for ContractVisualizerScreen { fn display_message(&mut self, _msg: &str, _t: crate::ui::MessageType) { - // Banner display is handled globally by AppState. + // 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) {} diff --git a/src/ui/tools/document_visualizer_screen.rs b/src/ui/tools/document_visualizer_screen.rs index 69ecd7ed6..bd2bc5633 100644 --- a/src/ui/tools/document_visualizer_screen.rs +++ b/src/ui/tools/document_visualizer_screen.rs @@ -195,7 +195,8 @@ impl DocumentVisualizerScreen { impl crate::ui::ScreenLike for DocumentVisualizerScreen { fn display_message(&mut self, _msg: &str, _t: crate::ui::MessageType) { - // Banner display is handled globally by AppState. + // 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) {} diff --git a/src/ui/wallets/send_screen.rs b/src/ui/wallets/send_screen.rs index e4fc57a28..6e7f7d5fa 100644 --- a/src/ui/wallets/send_screen.rs +++ b/src/ui/wallets/send_screen.rs @@ -14,7 +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}; +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; @@ -650,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")?; @@ -1661,16 +1671,7 @@ impl WalletSendScreen { if ui.add_enabled(can_send, send_button).clicked() { match self.validate_and_send() { Ok(send_action) => { - if let Some(h) = self.send_banner.take() { - h.clear(); - } - let handle = MessageBanner::set_global( - ui.ctx(), - "Sending transaction...", - MessageType::Info, - ); - handle.with_elapsed(); - self.send_banner = Some(handle); + self.set_send_progress_banner(ui.ctx()); action = send_action; } Err(e) => { @@ -2248,16 +2249,7 @@ impl WalletSendScreen { if ui.add_enabled(can_send, send_button).clicked() { match self.validate_and_send_advanced() { Ok(send_action) => { - if let Some(h) = self.send_banner.take() { - h.clear(); - } - let handle = MessageBanner::set_global( - ui.ctx(), - "Sending transaction...", - MessageType::Info, - ); - handle.with_elapsed(); - self.send_banner = Some(handle); + self.set_send_progress_banner(ui.ctx()); action = send_action; } Err(e) => { @@ -2646,15 +2638,11 @@ impl ScreenLike for WalletSendScreen { // Banner display is handled globally by AppState; this is only for side-effects. match message_type { MessageType::Error | MessageType::Warning => { - if let Some(h) = self.send_banner.take() { - h.clear(); - } + self.send_banner.take_and_clear(); self.send_status = SendStatus::Error; } MessageType::Success => { - if let Some(h) = self.send_banner.take() { - h.clear(); - } + self.send_banner.take_and_clear(); self.send_status = SendStatus::Complete(message.to_string()); } MessageType::Info => { @@ -2667,9 +2655,7 @@ impl ScreenLike for WalletSendScreen { &mut self, backend_task_success_result: crate::backend_task::BackendTaskSuccessResult, ) { - if let Some(h) = self.send_banner.take() { - h.clear(); - } + self.send_banner.take_and_clear(); match backend_task_success_result { crate::backend_task::BackendTaskSuccessResult::WalletPayment { txid: _, From 4bf4c481033c4767931f93fae78dceaa7595a1c4 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 27 Feb 2026 16:45:03 +0100 Subject: [PATCH 40/46] doc(tmp): review guide for pr 604 --- .../pr-604-review-guide.md | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 docs/ai-design/2026-02-27-banner-review-fixes/pr-604-review-guide.md 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. From 538b7da36aca54d354e01ef12948c97d3dd767be Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 27 Feb 2026 21:45:09 +0100 Subject: [PATCH 41/46] fix(ui): address review findings from grumpy-review iteration 1 - Replace .expect() panics in TransferTokensScreen and ClaimTokensScreen constructors with graceful degradation via Option and MessageBanner error display (PROJ-001 HIGH) - Fix CLAUDE.md referencing non-existent BannerHandleExt trait name, corrected to OptionBannerExt (PROJ-002 MEDIUM) - Update set_global to preserve message_type when same text appears with different severity (RUST-001 MEDIUM) - Standardize display_message to handle both Error and Warning across all 11 token screens (RUST-002 MEDIUM) - Replace 21 manual take().clear() patterns with take_and_clear() across 6 files (RUST-003 MEDIUM) - Remove unused OptionBannerExt::or_show_error method (RUST-004 MEDIUM) - Migrate update_token_config from old error_message pattern to set_error_banner closure (RUST-005 MEDIUM) Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 2 +- src/ui/components/message_banner.rs | 29 ++-- .../add_contracts_screen.rs | 14 +- .../contracts_documents_screen.rs | 34 +--- .../group_actions_screen.rs | 14 +- src/ui/dpns/dpns_contested_names_screen.rs | 16 +- src/ui/identities/transfer_screen.rs | 12 +- src/ui/tokens/burn_tokens_screen.rs | 2 +- src/ui/tokens/claim_tokens_screen.rs | 113 ++++++++----- src/ui/tokens/destroy_frozen_funds_screen.rs | 2 +- src/ui/tokens/direct_token_purchase_screen.rs | 2 +- src/ui/tokens/freeze_tokens_screen.rs | 2 +- src/ui/tokens/pause_tokens_screen.rs | 2 +- src/ui/tokens/set_token_price_screen.rs | 2 +- src/ui/tokens/transfer_tokens_screen.rs | 160 +++++++++++------- src/ui/tokens/unfreeze_tokens_screen.rs | 2 +- src/ui/tokens/update_token_config.rs | 45 +++-- src/ui/tools/transition_visualizer_screen.rs | 14 +- 18 files changed, 251 insertions(+), 216 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3739c7124..5268dd61d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -175,7 +175,7 @@ response.inner.update(&mut self.amount); 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 `BannerHandleExt`). 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. +**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/src/ui/components/message_banner.rs b/src/ui/components/message_banner.rs index 1ba8b6ec2..c68f1e7f4 100644 --- a/src/ui/components/message_banner.rs +++ b/src/ui/components/message_banner.rs @@ -313,7 +313,18 @@ impl MessageBanner { ) -> 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, @@ -727,31 +738,17 @@ impl ResultBannerExt for Result { } } -/// Extension for `Option` — error display and banner cleanup. +/// Extension for `Option` — banner cleanup. /// /// ```ignore -/// let identity = self.identities.first() -/// .or_show_error(ctx, "No identities loaded"); -/// /// self.refresh_banner.take_and_clear(); /// ``` pub trait OptionBannerExt { - /// 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, err: impl fmt::Display) -> Self; - /// Takes the handle (leaving `None`) and clears the associated banner. fn take_and_clear(&mut self); } impl OptionBannerExt for Option { - fn or_show_error(self, ctx: &egui::Context, err: impl fmt::Display) -> Self { - if self.is_none() { - MessageBanner::set_global(ctx, err, MessageType::Error); - } - self - } - fn take_and_clear(&mut self) { if let Some(h) = self.take() { h.clear(); diff --git a/src/ui/contracts_documents/add_contracts_screen.rs b/src/ui/contracts_documents/add_contracts_screen.rs index 3414c844f..03fb8ca93 100644 --- a/src/ui/contracts_documents/add_contracts_screen.rs +++ b/src/ui/contracts_documents/add_contracts_screen.rs @@ -3,7 +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}; +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; @@ -80,9 +80,7 @@ impl AddContractsScreen { fn add_contracts_clicked(&mut self) -> AppAction { match self.parse_identifiers() { Ok(identifiers) => { - if let Some(h) = self.add_banner.take() { - h.clear(); - } + self.add_banner.take_and_clear(); let handle = MessageBanner::set_global( self.app_context.egui_ctx(), "Adding contract...", @@ -280,9 +278,7 @@ impl ScreenLike for AddContractsScreen { // Banner display is handled globally by AppState; this is only for side-effects. match message_type { MessageType::Error | MessageType::Warning => { - if let Some(h) = self.add_banner.take() { - h.clear(); - } + self.add_banner.take_and_clear(); self.add_contracts_status = AddContractsStatus::Error; } MessageType::Success | MessageType::Info => {} @@ -292,9 +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) => { - if let Some(h) = self.add_banner.take() { - h.clear(); - } + self.add_banner.take_and_clear(); let maybe_contracts: Vec<_> = self .contract_ids_input .iter() diff --git a/src/ui/contracts_documents/contracts_documents_screen.rs b/src/ui/contracts_documents/contracts_documents_screen.rs index 0600ce856..3382368df 100644 --- a/src/ui/contracts_documents/contracts_documents_screen.rs +++ b/src/ui/contracts_documents/contracts_documents_screen.rs @@ -10,7 +10,7 @@ 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}; +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}; @@ -191,9 +191,7 @@ impl DocumentQueryScreen { match parser.parse_input(&self.document_query) { Ok(parsed_query) => { // Set the status to waiting - if let Some(h) = self.query_banner.take() { - h.clear(); - } + self.query_banner.take_and_clear(); let handle = MessageBanner::set_global( ui.ctx(), "Querying documents...", @@ -210,9 +208,7 @@ impl DocumentQueryScreen { ))); } Err(e) => { - if let Some(h) = self.query_banner.take() { - h.clear(); - } + self.query_banner.take_and_clear(); self.document_query_status = DocumentQueryStatus::Error; MessageBanner::set_global( ui.ctx(), @@ -361,9 +357,7 @@ impl DocumentQueryScreen { if self.current_page > 1 && ui.button("Previous Page").clicked() { // Handle Previous Page if let Some(prev_cursor) = self.get_previous_cursor() { - if let Some(h) = self.query_banner.take() { - h.clear(); - } + self.query_banner.take_and_clear(); let handle = MessageBanner::set_global( ui.ctx(), "Querying documents...", @@ -379,9 +373,7 @@ impl DocumentQueryScreen { DocumentTask::FetchDocumentsPage(parsed_query), ))); } else { - if let Some(h) = self.query_banner.take() { - h.clear(); - } + self.query_banner.take_and_clear(); let handle = MessageBanner::set_global( ui.ctx(), "Querying documents...", @@ -405,9 +397,7 @@ impl DocumentQueryScreen { if self.has_next_page && ui.button("Next Page").clicked() { // Handle Next Page if let Some(next_cursor) = &self.get_next_cursor() { - if let Some(h) = self.query_banner.take() { - h.clear(); - } + self.query_banner.take_and_clear(); let handle = MessageBanner::set_global( ui.ctx(), "Querying documents...", @@ -555,9 +545,7 @@ impl ScreenLike for DocumentQueryScreen { if message.contains("Error fetching documents") && matches!(message_type, MessageType::Error | MessageType::Warning) { - if let Some(h) = self.query_banner.take() { - h.clear(); - } + self.query_banner.take_and_clear(); self.document_query_status = DocumentQueryStatus::Error; } } @@ -565,9 +553,7 @@ impl ScreenLike for DocumentQueryScreen { fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { match backend_task_success_result { BackendTaskSuccessResult::Documents(documents) => { - if let Some(h) = self.query_banner.take() { - h.clear(); - } + self.query_banner.take_and_clear(); self.matching_documents = documents .iter() .filter_map(|(_, doc)| doc.clone()) @@ -575,9 +561,7 @@ impl ScreenLike for DocumentQueryScreen { self.document_query_status = DocumentQueryStatus::Complete; } BackendTaskSuccessResult::PageDocuments(page_docs, next_cursor) => { - if let Some(h) = self.query_banner.take() { - h.clear(); - } + self.query_banner.take_and_clear(); self.matching_documents = page_docs .iter() .filter_map(|(_, doc)| doc.clone()) diff --git a/src/ui/contracts_documents/group_actions_screen.rs b/src/ui/contracts_documents/group_actions_screen.rs index 2aa7d4637..ed877deb6 100644 --- a/src/ui/contracts_documents/group_actions_screen.rs +++ b/src/ui/contracts_documents/group_actions_screen.rs @@ -17,7 +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}; +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; @@ -461,9 +461,7 @@ impl ScreenLike for GroupActionsScreen { // Banner display is handled globally by AppState; this is only for side-effects. match message_type { MessageType::Error | MessageType::Warning => { - if let Some(h) = self.fetch_banner.take() { - h.clear(); - } + self.fetch_banner.take_and_clear(); self.fetch_group_actions_status = FetchGroupActionsStatus::Error; } MessageType::Success | MessageType::Info => {} @@ -474,9 +472,7 @@ impl ScreenLike for GroupActionsScreen { if let BackendTaskSuccessResult::ActiveGroupActions(actions_map) = backend_task_success_result { - if let Some(h) = self.fetch_banner.take() { - h.clear(); - } + self.fetch_banner.take_and_clear(); self.fetch_group_actions_status = FetchGroupActionsStatus::Complete(actions_map.clone()); } @@ -568,9 +564,7 @@ impl ScreenLike for GroupActionsScreen { .corner_radius(3.0); if ui.add(button).clicked() { - if let Some(h) = self.fetch_banner.take() { - h.clear(); - } + self.fetch_banner.take_and_clear(); let handle = MessageBanner::set_global( ui.ctx(), "Fetching group actions...", diff --git a/src/ui/dpns/dpns_contested_names_screen.rs b/src/ui/dpns/dpns_contested_names_screen.rs index 2e283c7fe..48bf268b2 100644 --- a/src/ui/dpns/dpns_contested_names_screen.rs +++ b/src/ui/dpns/dpns_contested_names_screen.rs @@ -1542,9 +1542,7 @@ impl DPNSScreen { if ui.add(button).clicked() { action = self.bulk_apply_votes(); if self.bulk_vote_handling_status == VoteHandlingStatus::CastingVotes { - if let Some(h) = self.vote_banner.take() { - h.clear(); - } + self.vote_banner.take_and_clear(); let handle = MessageBanner::set_global(ui.ctx(), "Casting votes...", MessageType::Info); handle.with_elapsed(); @@ -1558,9 +1556,7 @@ impl DPNSScreen { self.show_bulk_schedule_popup = false; self.bulk_schedule_message = None; self.bulk_vote_handling_status = VoteHandlingStatus::NotStarted; - if let Some(h) = self.vote_banner.take() { - h.clear(); - } + self.vote_banner.take_and_clear(); } // Handle status @@ -1887,17 +1883,13 @@ impl ScreenLike for DPNSScreen { )); } - if let Some(h) = self.vote_banner.take() { - h.clear(); - } + 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 { - if let Some(h) = self.vote_banner.take() { - h.clear(); - } + self.vote_banner.take_and_clear(); self.bulk_vote_handling_status = VoteHandlingStatus::Completed; } self.bulk_schedule_message = diff --git a/src/ui/identities/transfer_screen.rs b/src/ui/identities/transfer_screen.rs index a1134046c..a2247b8a0 100644 --- a/src/ui/identities/transfer_screen.rs +++ b/src/ui/identities/transfer_screen.rs @@ -13,7 +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, ResultBannerExt}; +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; @@ -503,10 +503,8 @@ impl TransferScreen { impl ScreenLike for TransferScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. - if let MessageType::Error = message_type { - if let Some(handle) = self.refresh_banner.take() { - handle.clear(); - } + if matches!(message_type, MessageType::Error | MessageType::Warning) { + self.refresh_banner.take_and_clear(); self.transfer_credits_status = TransferCreditsStatus::Error; } } @@ -515,9 +513,7 @@ impl ScreenLike for TransferScreen { if let BackendTaskSuccessResult::TransferredCredits(fee_result) = backend_task_success_result { - if let Some(handle) = self.refresh_banner.take() { - handle.clear(); - } + self.refresh_banner.take_and_clear(); self.completed_fee_result = Some(fee_result); self.transfer_credits_status = TransferCreditsStatus::Complete; } diff --git a/src/ui/tokens/burn_tokens_screen.rs b/src/ui/tokens/burn_tokens_screen.rs index e19dba11a..39ccba1b0 100644 --- a/src/ui/tokens/burn_tokens_screen.rs +++ b/src/ui/tokens/burn_tokens_screen.rs @@ -349,7 +349,7 @@ impl BurnTokensScreen { impl ScreenLike for BurnTokensScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. - if let MessageType::Error = message_type { + if matches!(message_type, MessageType::Error | MessageType::Warning) { self.refresh_banner.take_and_clear(); self.status = BurnTokensStatus::Error; } diff --git a/src/ui/tokens/claim_tokens_screen.rs b/src/ui/tokens/claim_tokens_screen.rs index fbbacaa85..2cdae13d2 100644 --- a/src/ui/tokens/claim_tokens_screen.rs +++ b/src/ui/tokens/claim_tokens_screen.rs @@ -51,7 +51,7 @@ pub enum ClaimTokensStatus { } pub struct ClaimTokensScreen { - identity: QualifiedIdentity, + identity: Option, pub identity_token_basic_info: IdentityTokenBasicInfo, selected_key: Option, show_advanced_options: bool, @@ -94,29 +94,41 @@ impl ClaimTokensScreen { ); // Fallback to first available identity for degraded state. known_identities.first().cloned() - }) - .expect("UI prevents opening this screen without loaded identities"); - - 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, - ); + }); - 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, + if identity.is_none() { + MessageBanner::set_global( + app_context.egui_ctx(), + "No identities loaded — cannot open Claim screen", + MessageType::Error, ); } - let selected_wallet = get_selected_wallet(&identity, None, possible_key) - .or_show_error(app_context.egui_ctx()) - .unwrap_or(None); + 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 @@ -137,7 +149,7 @@ 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, @@ -154,6 +166,7 @@ impl ClaimTokensScreen { } 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() @@ -161,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 { @@ -220,6 +235,15 @@ impl ClaimTokensScreen { Some(ConfirmationStatus::Confirmed) => { self.confirmation_dialog = 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()) @@ -241,7 +265,7 @@ impl ClaimTokensScreen { 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(), @@ -272,7 +296,7 @@ impl ClaimTokensScreen { impl ScreenLike for ClaimTokensScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. - if let MessageType::Error = message_type { + if matches!(message_type, MessageType::Error | MessageType::Warning) { self.refresh_banner.take_and_clear(); self.status = ClaimTokensStatus::Error; } @@ -287,12 +311,12 @@ impl ScreenLike for ClaimTokensScreen { } 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); } } @@ -327,20 +351,29 @@ impl ScreenLike for ClaimTokensScreen { return; } + // Guard: require a loaded identity before rendering interactive content. + // Clone early so the borrow is released before mutable method calls below. + let Some(identity) = self.identity.clone() 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() } } }; @@ -350,12 +383,12 @@ impl ScreenLike for ClaimTokensScreen { Color32::RED, format!( "No authentication keys with CRITICAL security level found for this {} identity.", - self.identity.identity_type, + identity.identity_type, ), ); ui.add_space(10.0); - let first_key = self.identity.identity.get_first_public_key_matching( + let first_key = identity.identity.get_first_public_key_matching( Purpose::AUTHENTICATION, HashSet::from([SecurityLevel::CRITICAL]), KeyType::all_key_types().into(), @@ -365,7 +398,7 @@ impl ScreenLike for ClaimTokensScreen { if let Some(key) = first_key { if ui.button("Check Keys").clicked() { action |= AppAction::AddScreen(Screen::KeyInfoScreen(KeyInfoScreen::new( - self.identity.clone(), + identity.clone(), key.clone(), None, &self.app_context, @@ -376,7 +409,7 @@ impl ScreenLike for ClaimTokensScreen { if ui.button("Add key").clicked() { action |= AppAction::AddScreen(Screen::AddKeyScreen(AddKeyScreen::new( - self.identity.clone(), + identity.clone(), &self.app_context, ))); } @@ -416,7 +449,7 @@ impl ScreenLike for ClaimTokensScreen { add_key_chooser( ui, &self.app_context, - &self.identity, + &identity, &mut self.selected_key, TransactionType::TokenClaim, ); diff --git a/src/ui/tokens/destroy_frozen_funds_screen.rs b/src/ui/tokens/destroy_frozen_funds_screen.rs index ad78c96b4..01732c252 100644 --- a/src/ui/tokens/destroy_frozen_funds_screen.rs +++ b/src/ui/tokens/destroy_frozen_funds_screen.rs @@ -340,7 +340,7 @@ impl DestroyFrozenFundsScreen { impl ScreenLike for DestroyFrozenFundsScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. - if let MessageType::Error = message_type { + if matches!(message_type, MessageType::Error | MessageType::Warning) { self.refresh_banner.take_and_clear(); self.status = DestroyFrozenFundsStatus::Error; } diff --git a/src/ui/tokens/direct_token_purchase_screen.rs b/src/ui/tokens/direct_token_purchase_screen.rs index 4a32f5ace..ebbd63f01 100644 --- a/src/ui/tokens/direct_token_purchase_screen.rs +++ b/src/ui/tokens/direct_token_purchase_screen.rs @@ -378,7 +378,7 @@ impl ScreenLike for PurchaseTokenScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. - if let MessageType::Error = message_type { + if matches!(message_type, MessageType::Error | MessageType::Warning) { self.refresh_banner.take_and_clear(); self.status = PurchaseTokensStatus::Error; } diff --git a/src/ui/tokens/freeze_tokens_screen.rs b/src/ui/tokens/freeze_tokens_screen.rs index 48619c167..67f93c977 100644 --- a/src/ui/tokens/freeze_tokens_screen.rs +++ b/src/ui/tokens/freeze_tokens_screen.rs @@ -332,7 +332,7 @@ impl FreezeTokensScreen { impl ScreenLike for FreezeTokensScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. - if let MessageType::Error = message_type { + if matches!(message_type, MessageType::Error | MessageType::Warning) { self.refresh_banner.take_and_clear(); self.status = FreezeTokensStatus::Error; } diff --git a/src/ui/tokens/pause_tokens_screen.rs b/src/ui/tokens/pause_tokens_screen.rs index 327840b65..1af503fed 100644 --- a/src/ui/tokens/pause_tokens_screen.rs +++ b/src/ui/tokens/pause_tokens_screen.rs @@ -281,7 +281,7 @@ impl PauseTokensScreen { impl ScreenLike for PauseTokensScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. - if let MessageType::Error = message_type { + if matches!(message_type, MessageType::Error | MessageType::Warning) { self.refresh_banner.take_and_clear(); self.status = PauseTokensStatus::Error; } diff --git a/src/ui/tokens/set_token_price_screen.rs b/src/ui/tokens/set_token_price_screen.rs index 9e3c6d9e2..364008658 100644 --- a/src/ui/tokens/set_token_price_screen.rs +++ b/src/ui/tokens/set_token_price_screen.rs @@ -828,7 +828,7 @@ impl SetTokenPriceScreen { impl ScreenLike for SetTokenPriceScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. - if let MessageType::Error = message_type { + if matches!(message_type, MessageType::Error | MessageType::Warning) { self.refresh_banner.take_and_clear(); self.status = SetTokenPriceStatus::Error; } diff --git a/src/ui/tokens/transfer_tokens_screen.rs b/src/ui/tokens/transfer_tokens_screen.rs index 07dd6caa2..4e73178e6 100644 --- a/src/ui/tokens/transfer_tokens_screen.rs +++ b/src/ui/tokens/transfer_tokens_screen.rs @@ -47,7 +47,7 @@ pub enum TransferTokensStatus { } pub struct TransferTokensScreen { - identity: QualifiedIdentity, + identity: Option, pub identity_token_balance: IdentityTokenBalance, known_identities: Vec, selected_key: Option, @@ -90,19 +90,32 @@ impl TransferTokensScreen { ); // Fallback to first available identity for degraded state. known_identities.first().cloned() - }) - .expect("UI prevents opening this screen without loaded identities"); + }); + + 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 selected_wallet = get_selected_wallet(&identity, None, selected_key) - .or_show_error(app_context.egui_ctx()) - .unwrap_or(None); + 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)); @@ -110,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(), @@ -167,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", @@ -175,7 +194,7 @@ impl TransferTokensScreen { ) .width(300.0) .label("Recipient:") - .exclude(&[self.identity.identity.id()]), + .exclude(&exclude), ); } @@ -254,6 +273,15 @@ impl TransferTokensScreen { } }; + 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(), @@ -265,7 +293,7 @@ impl TransferTokensScreen { 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, @@ -288,7 +316,7 @@ impl TransferTokensScreen { impl ScreenLike for TransferTokensScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. - if let MessageType::Error = message_type { + if matches!(message_type, MessageType::Error | MessageType::Warning) { self.refresh_banner.take_and_clear(); self.transfer_tokens_status = TransferTokensStatus::Error; } @@ -305,40 +333,45 @@ impl ScreenLike for TransferTokensScreen { fn refresh(&mut self) { // Refresh the identity because there might be new keys - if let Some(refreshed) = 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![] - }) - .into_iter() - .find(|identity| identity.identity.id() == self.identity.identity.id()) - { - self.identity = refreshed; - } - 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 == 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); } - Err(e) => { - MessageBanner::set_global( - self.app_context.egui_ctx(), - format!("Failed to load token balances: {e}"), - MessageType::Error, - ); + } + 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, + ); + } } } } @@ -377,6 +410,16 @@ impl ScreenLike for TransferTokensScreen { return self.show_success(ui); } + // Guard: require a loaded identity before rendering interactive content. + // Clone early so the borrow is released before mutable method calls below. + let Some(identity) = self.identity.clone() 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 @@ -384,10 +427,9 @@ 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() }; @@ -397,12 +439,12 @@ impl ScreenLike for TransferTokensScreen { 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.identity_type ), ); ui.add_space(10.0); - let key = self.identity.identity.get_first_public_key_matching( + let key = identity.identity.get_first_public_key_matching( Purpose::AUTHENTICATION, HashSet::from([SecurityLevel::CRITICAL]), KeyType::all_key_types().into(), @@ -412,7 +454,7 @@ impl ScreenLike for TransferTokensScreen { if let Some(key) = key { if ui.button("Check Keys").clicked() { return AppAction::AddScreen(Screen::KeyInfoScreen(KeyInfoScreen::new( - self.identity.clone(), + identity.clone(), key.clone(), None, &self.app_context, @@ -423,7 +465,7 @@ impl ScreenLike for TransferTokensScreen { if ui.button("Add key").clicked() { return AppAction::AddScreen(Screen::AddKeyScreen(AddKeyScreen::new( - self.identity.clone(), + identity.clone(), &self.app_context, ))); } @@ -462,7 +504,7 @@ impl ScreenLike for TransferTokensScreen { add_key_chooser( ui, &self.app_context, - &self.identity, + &identity, &mut self.selected_key, TransactionType::TokenTransfer, ); @@ -539,7 +581,7 @@ impl ScreenLike for TransferTokensScreen { // Transfer button - let has_enough_balance = self.identity.identity.balance() > estimated_fee; + 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() diff --git a/src/ui/tokens/unfreeze_tokens_screen.rs b/src/ui/tokens/unfreeze_tokens_screen.rs index 6c680f7e4..1778c0e04 100644 --- a/src/ui/tokens/unfreeze_tokens_screen.rs +++ b/src/ui/tokens/unfreeze_tokens_screen.rs @@ -335,7 +335,7 @@ impl UnfreezeTokensScreen { impl ScreenLike for UnfreezeTokensScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. - if let MessageType::Error = message_type { + if matches!(message_type, MessageType::Error | MessageType::Warning) { self.refresh_banner.take_and_clear(); self.status = UnfreezeTokensStatus::Error; } diff --git a/src/ui/tokens/update_token_config.rs b/src/ui/tokens/update_token_config.rs index ba0787132..f17cb6c5f 100644 --- a/src/ui/tokens/update_token_config.rs +++ b/src/ui/tokens/update_token_config.rs @@ -132,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 } @@ -173,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 } } @@ -189,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 } } @@ -197,9 +208,6 @@ impl UpdateTokenConfigScreen { }; self.group = group; - if let Some(error) = error_message { - MessageBanner::set_global(self.app_context.egui_ctx(), &error, MessageType::Error); - } // Update is_unilateral_group_member based on new group self.is_unilateral_group_member = false; @@ -766,12 +774,13 @@ impl UpdateTokenConfigScreen { validate_signing_key(&self.app_context, self.signing_key.as_ref()) { self.update_status = UpdateTokenConfigStatus::Updating; - self.refresh_banner = Some(MessageBanner::set_global( + let handle = MessageBanner::set_global( ui.ctx(), "Updating token configuration...", MessageType::Info, - )); - self.refresh_banner.as_ref().and_then(|h| h.with_elapsed()); + ); + 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()), @@ -924,7 +933,7 @@ impl UpdateTokenConfigScreen { impl ScreenLike for UpdateTokenConfigScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. - if message_type == MessageType::Error { + if matches!(message_type, MessageType::Error | MessageType::Warning) { self.refresh_banner.take_and_clear(); self.update_status = UpdateTokenConfigStatus::NotUpdating; } diff --git a/src/ui/tools/transition_visualizer_screen.rs b/src/ui/tools/transition_visualizer_screen.rs index 61c71d71b..4e5382974 100644 --- a/src/ui/tools/transition_visualizer_screen.rs +++ b/src/ui/tools/transition_visualizer_screen.rs @@ -3,7 +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}; +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; @@ -237,9 +237,7 @@ impl TransitionVisualizerScreen { if ui.add(button).clicked() { // Mark as submitting - if let Some(h) = self.submit_banner.take() { - h.clear(); - } + self.submit_banner.take_and_clear(); let handle = MessageBanner::set_global( ui.ctx(), "Submitting transition...", @@ -389,16 +387,12 @@ impl ScreenLike for TransitionVisualizerScreen { match message_type { MessageType::Success => { if matches!(self.broadcast_status, TransitionBroadcastStatus::Submitting) { - if let Some(h) = self.submit_banner.take() { - h.clear(); - } + self.submit_banner.take_and_clear(); self.broadcast_status = TransitionBroadcastStatus::Complete(Instant::now()); } } MessageType::Error | MessageType::Warning => { - if let Some(h) = self.submit_banner.take() { - h.clear(); - } + self.submit_banner.take_and_clear(); self.broadcast_status = TransitionBroadcastStatus::NotStarted; } MessageType::Info => {} From 70e0fb7ae6a1dc2b1524c4fd10f34a6140863031 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 27 Feb 2026 21:52:33 +0100 Subject: [PATCH 42/46] perf(ui): replace per-frame QualifiedIdentity clone with borrow Use .as_ref() instead of .clone() in the ui() identity guard of TransferTokensScreen and ClaimTokensScreen. QualifiedIdentity (Identity + KeyStorage + BTreeMap + Vec) was being cloned 60x/sec; now only borrowed for display, with clones deferred to button-click paths that actually need ownership. Co-Authored-By: Claude Opus 4.6 --- src/ui/tokens/claim_tokens_screen.rs | 38 +++++++++++++++--------- src/ui/tokens/transfer_tokens_screen.rs | 39 ++++++++++++++++--------- 2 files changed, 49 insertions(+), 28 deletions(-) diff --git a/src/ui/tokens/claim_tokens_screen.rs b/src/ui/tokens/claim_tokens_screen.rs index 2cdae13d2..f0534f500 100644 --- a/src/ui/tokens/claim_tokens_screen.rs +++ b/src/ui/tokens/claim_tokens_screen.rs @@ -352,8 +352,8 @@ impl ScreenLike for ClaimTokensScreen { } // Guard: require a loaded identity before rendering interactive content. - // Clone early so the borrow is released before mutable method calls below. - let Some(identity) = self.identity.clone() else { + // 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.", @@ -379,27 +379,33 @@ impl ScreenLike for ClaimTokensScreen { }; 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.", - identity.identity_type, + identity_type, ), ); ui.add_space(10.0); - let first_key = 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( - identity.clone(), - key.clone(), + identity, + key, None, &self.app_context, ))); @@ -408,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( - identity.clone(), + identity, &self.app_context, ))); } @@ -446,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, - &identity, + identity, &mut self.selected_key, TransactionType::TokenClaim, ); diff --git a/src/ui/tokens/transfer_tokens_screen.rs b/src/ui/tokens/transfer_tokens_screen.rs index 4e73178e6..5005ebee2 100644 --- a/src/ui/tokens/transfer_tokens_screen.rs +++ b/src/ui/tokens/transfer_tokens_screen.rs @@ -411,8 +411,8 @@ impl ScreenLike for TransferTokensScreen { } // Guard: require a loaded identity before rendering interactive content. - // Clone early so the borrow is released before mutable method calls below. - let Some(identity) = self.identity.clone() else { + // 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.", @@ -435,27 +435,33 @@ impl ScreenLike for TransferTokensScreen { }; 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.", - identity.identity_type + identity_type ), ); ui.add_space(10.0); - let key = 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( - identity.clone(), - key.clone(), + identity, + key, None, &self.app_context, ))); @@ -464,8 +470,10 @@ 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( - identity.clone(), + identity, &self.app_context, ))); } @@ -501,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, - &identity, + identity, &mut self.selected_key, TransactionType::TokenTransfer, ); @@ -581,6 +591,7 @@ impl ScreenLike for TransferTokensScreen { // Transfer button + 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() From f3a5d4ed3f55fefa5c3175304fd5075132b03b26 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 27 Feb 2026 22:06:49 +0100 Subject: [PATCH 43/46] feat(ui): add OptionResultExt::or_show_error for Option Mirrors ResultBannerExt::or_show_error but for Option: if None, displays a global error banner with the given message. Enables concise patterns like: identities.first().cloned().or_show_error(ctx, "No identities loaded") Co-Authored-By: Claude Opus 4.6 --- src/ui/components/message_banner.rs | 21 +++++++++++++++++++++ src/ui/components/mod.rs | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/ui/components/message_banner.rs b/src/ui/components/message_banner.rs index c68f1e7f4..ea346684c 100644 --- a/src/ui/components/message_banner.rs +++ b/src/ui/components/message_banner.rs @@ -738,6 +738,27 @@ impl ResultBannerExt for Result { } } +/// 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 OptionResultExt { + /// 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 OptionResultExt 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 diff --git a/src/ui/components/mod.rs b/src/ui/components/mod.rs index 0bf874de1..d5e72f79e 100644 --- a/src/ui/components/mod.rs +++ b/src/ui/components/mod.rs @@ -21,5 +21,5 @@ pub mod wallet_unlock_popup; pub use component_trait::{Component, ComponentResponse}; pub use message_banner::{ BannerHandle, BannerStatus, MessageBanner, MessageBannerResponse, OptionBannerExt, - ResultBannerExt, + OptionResultExt, ResultBannerExt, }; From 0dbce61f8a3641a9ed150833d7783212dae2f7a9 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 27 Feb 2026 22:19:42 +0100 Subject: [PATCH 44/46] fix(ui): address review findings from grumpy-review iteration 2 - Standardize display_message to handle both Error and Warning across 13 non-token screens that were missed in iteration 1 (PROJ-001 MEDIUM) - Replace .expect() panic in AddKeyScreen::refresh() with graceful or_show_error() + unwrap_or_default() (PROJ-002 MEDIUM) - Rename OptionResultExt to OptionBannerShowExt to avoid confusion with ResultBannerExt (RUST-001 MEDIUM) Co-Authored-By: Claude Opus 4.6 --- src/ui/components/message_banner.rs | 4 ++-- src/ui/components/mod.rs | 2 +- src/ui/contracts_documents/register_contract_screen.rs | 2 +- src/ui/contracts_documents/update_contract_screen.rs | 2 +- src/ui/dashpay/add_contact_screen.rs | 2 +- src/ui/dashpay/contact_requests.rs | 2 +- src/ui/dashpay/profile_screen.rs | 2 +- src/ui/identities/keys/add_key_screen.rs | 8 +++++--- src/ui/identities/register_dpns_name_screen.rs | 2 +- src/ui/identities/withdraw_screen.rs | 2 +- src/ui/tools/address_balance_screen.rs | 2 +- src/ui/tools/grovestark_screen.rs | 5 ++++- src/ui/tools/platform_info_screen.rs | 2 +- src/ui/wallets/single_key_send_screen.rs | 2 +- src/ui/wallets/wallets_screen/mod.rs | 2 +- 15 files changed, 23 insertions(+), 18 deletions(-) diff --git a/src/ui/components/message_banner.rs b/src/ui/components/message_banner.rs index ea346684c..c80e6a088 100644 --- a/src/ui/components/message_banner.rs +++ b/src/ui/components/message_banner.rs @@ -744,13 +744,13 @@ impl ResultBannerExt for Result { /// let identity = identities.first().cloned() /// .or_show_error(ctx, "No identities loaded"); /// ``` -pub trait OptionResultExt { +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 OptionResultExt for Option { +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); diff --git a/src/ui/components/mod.rs b/src/ui/components/mod.rs index d5e72f79e..95dc26d8c 100644 --- a/src/ui/components/mod.rs +++ b/src/ui/components/mod.rs @@ -21,5 +21,5 @@ pub mod wallet_unlock_popup; pub use component_trait::{Component, ComponentResponse}; pub use message_banner::{ BannerHandle, BannerStatus, MessageBanner, MessageBannerResponse, OptionBannerExt, - OptionResultExt, ResultBannerExt, + OptionBannerShowExt, ResultBannerExt, }; diff --git a/src/ui/contracts_documents/register_contract_screen.rs b/src/ui/contracts_documents/register_contract_screen.rs index dbf9920f6..e10fb553d 100644 --- a/src/ui/contracts_documents/register_contract_screen.rs +++ b/src/ui/contracts_documents/register_contract_screen.rs @@ -342,7 +342,7 @@ impl RegisterDataContractScreen { impl ScreenLike for RegisterDataContractScreen { fn display_message(&mut self, message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. - if message_type == MessageType::Error { + if matches!(message_type, MessageType::Error | MessageType::Warning) { if message.contains("proof error logged, contract inserted into the database") { self.broadcast_status = BroadcastStatus::Done; } else { diff --git a/src/ui/contracts_documents/update_contract_screen.rs b/src/ui/contracts_documents/update_contract_screen.rs index 6605e632c..4d3edaac1 100644 --- a/src/ui/contracts_documents/update_contract_screen.rs +++ b/src/ui/contracts_documents/update_contract_screen.rs @@ -362,7 +362,7 @@ impl UpdateDataContractScreen { impl ScreenLike for UpdateDataContractScreen { fn display_message(&mut self, message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. - if message_type == MessageType::Error { + if matches!(message_type, MessageType::Error | MessageType::Warning) { if message.contains("proof error logged, contract inserted into the database") { self.broadcast_status = BroadcastStatus::Done; } else { diff --git a/src/ui/dashpay/add_contact_screen.rs b/src/ui/dashpay/add_contact_screen.rs index 9554786ee..a9a3de65d 100644 --- a/src/ui/dashpay/add_contact_screen.rs +++ b/src/ui/dashpay/add_contact_screen.rs @@ -598,7 +598,7 @@ impl ScreenLike for AddContactScreen { fn display_message(&mut self, message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. - if message_type == MessageType::Error { + if matches!(message_type, MessageType::Error | MessageType::Warning) { let error = DashPayError::Internal { message: message.to_string(), }; diff --git a/src/ui/dashpay/contact_requests.rs b/src/ui/dashpay/contact_requests.rs index de65195a0..f2f2af90f 100644 --- a/src/ui/dashpay/contact_requests.rs +++ b/src/ui/dashpay/contact_requests.rs @@ -1035,7 +1035,7 @@ impl ScreenLike for ContactRequests { 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); } else if message.contains("DECRYPTION key") { diff --git a/src/ui/dashpay/profile_screen.rs b/src/ui/dashpay/profile_screen.rs index bcf184788..51688779b 100644 --- a/src/ui/dashpay/profile_screen.rs +++ b/src/ui/dashpay/profile_screen.rs @@ -1380,7 +1380,7 @@ impl ProfileScreen { 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 message_type == MessageType::Error { + if matches!(message_type, MessageType::Error | MessageType::Warning) { self.loading = false; self.saving = false; } diff --git a/src/ui/identities/keys/add_key_screen.rs b/src/ui/identities/keys/add_key_screen.rs index 5a6131607..ea24319c7 100644 --- a/src/ui/identities/keys/add_key_screen.rs +++ b/src/ui/identities/keys/add_key_screen.rs @@ -335,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()) { @@ -348,7 +350,7 @@ impl ScreenLike for AddKeyScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { // Error/success display is handled by the global MessageBanner. - if let MessageType::Error = message_type { + if matches!(message_type, MessageType::Error | MessageType::Warning) { self.refresh_banner.take_and_clear(); self.add_key_status = AddKeyStatus::Error; } diff --git a/src/ui/identities/register_dpns_name_screen.rs b/src/ui/identities/register_dpns_name_screen.rs index 376feec3a..28a798ad9 100644 --- a/src/ui/identities/register_dpns_name_screen.rs +++ b/src/ui/identities/register_dpns_name_screen.rs @@ -295,7 +295,7 @@ impl RegisterDpnsNameScreen { impl ScreenLike for RegisterDpnsNameScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. - if let MessageType::Error = message_type { + if matches!(message_type, MessageType::Error | MessageType::Warning) { self.refresh_banner.take_and_clear(); self.register_dpns_name_status = RegisterDpnsNameStatus::Error; } diff --git a/src/ui/identities/withdraw_screen.rs b/src/ui/identities/withdraw_screen.rs index b33ac65ff..bebd416e2 100644 --- a/src/ui/identities/withdraw_screen.rs +++ b/src/ui/identities/withdraw_screen.rs @@ -328,7 +328,7 @@ impl WithdrawalScreen { impl ScreenLike for WithdrawalScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. - if let MessageType::Error = message_type { + if matches!(message_type, MessageType::Error | MessageType::Warning) { self.refresh_banner.take_and_clear(); self.withdraw_from_identity_status = WithdrawFromIdentityStatus::Error; } diff --git a/src/ui/tools/address_balance_screen.rs b/src/ui/tools/address_balance_screen.rs index 387cadd69..165aeccae 100644 --- a/src/ui/tools/address_balance_screen.rs +++ b/src/ui/tools/address_balance_screen.rs @@ -126,7 +126,7 @@ impl AddressBalanceScreen { impl ScreenLike for AddressBalanceScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. - if message_type == MessageType::Error { + if matches!(message_type, MessageType::Error | MessageType::Warning) { self.is_loading = false; } } diff --git a/src/ui/tools/grovestark_screen.rs b/src/ui/tools/grovestark_screen.rs index 4917651bd..a1e6944a9 100644 --- a/src/ui/tools/grovestark_screen.rs +++ b/src/ui/tools/grovestark_screen.rs @@ -996,7 +996,10 @@ impl ScreenLike for GroveSTARKScreen { 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 message_type == crate::ui::MessageType::Error { + if matches!( + message_type, + crate::ui::MessageType::Error | crate::ui::MessageType::Warning + ) { self.is_generating = false; self.is_verifying = false; } diff --git a/src/ui/tools/platform_info_screen.rs b/src/ui/tools/platform_info_screen.rs index 1cb4b2544..22a5d0b85 100644 --- a/src/ui/tools/platform_info_screen.rs +++ b/src/ui/tools/platform_info_screen.rs @@ -219,7 +219,7 @@ impl ScreenLike for PlatformInfoScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. - if message_type == MessageType::Error { + if matches!(message_type, MessageType::Error | MessageType::Warning) { self.active_tasks.clear(); } } diff --git a/src/ui/wallets/single_key_send_screen.rs b/src/ui/wallets/single_key_send_screen.rs index 50e203e62..1a7f3963a 100644 --- a/src/ui/wallets/single_key_send_screen.rs +++ b/src/ui/wallets/single_key_send_screen.rs @@ -940,7 +940,7 @@ 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 diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index 9f5e4ac4a..6ff955525 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -1935,7 +1935,7 @@ impl ScreenLike for WalletsBalancesScreen { fn display_message(&mut self, message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. - if let MessageType::Error = message_type { + if matches!(message_type, MessageType::Error | MessageType::Warning) { self.refreshing = false; // If the fund platform dialog is processing, show error in the dialog instead From 31e45033eb0ba70d99531f131616527a12fb326a Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 27 Feb 2026 22:23:09 +0100 Subject: [PATCH 45/46] fix(ui): handle Warning in add_new_identity_screen display_message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Missed in the previous sweep — standardize display_message to handle both Error and Warning, matching all other screens. Co-Authored-By: Claude Opus 4.6 --- src/ui/identities/add_new_identity_screen/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/identities/add_new_identity_screen/mod.rs b/src/ui/identities/add_new_identity_screen/mod.rs index 5064bda17..80479aab6 100644 --- a/src/ui/identities/add_new_identity_screen/mod.rs +++ b/src/ui/identities/add_new_identity_screen/mod.rs @@ -1021,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(); From a461fb673a4f0b2aa99b359ddb9e50b21533c33a Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 27 Feb 2026 22:32:30 +0100 Subject: [PATCH 46/46] fix(ui): standardize display_message side-effect patterns across screens Guard side effects with Error|Warning match, use take_and_clear(), and remove redundant MessageBanner::set_global() call in 4 screens. Co-Authored-By: Claude Opus 4.6 --- src/ui/contracts_documents/document_action_screen.rs | 9 +++++++-- src/ui/identities/identities_screen.rs | 6 ++---- src/ui/tokens/tokens_screen/mod.rs | 12 ++++++------ src/ui/tools/masternode_list_diff_screen.rs | 3 +-- 4 files changed, 16 insertions(+), 14 deletions(-) diff --git a/src/ui/contracts_documents/document_action_screen.rs b/src/ui/contracts_documents/document_action_screen.rs index 3d0dbb26e..16b85598f 100644 --- a/src/ui/contracts_documents/document_action_screen.rs +++ b/src/ui/contracts_documents/document_action_screen.rs @@ -1638,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) { + 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. - self.broadcast_status = BroadcastStatus::NotBroadcasted; + 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) { diff --git a/src/ui/identities/identities_screen.rs b/src/ui/identities/identities_screen.rs index e3e473eca..23bfc5af5 100644 --- a/src/ui/identities/identities_screen.rs +++ b/src/ui/identities/identities_screen.rs @@ -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(); } } diff --git a/src/ui/tokens/tokens_screen/mod.rs b/src/ui/tokens/tokens_screen/mod.rs index a5fca535b..4c1cc3fe2 100644 --- a/src/ui/tokens/tokens_screen/mod.rs +++ b/src/ui/tokens/tokens_screen/mod.rs @@ -65,7 +65,7 @@ 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; +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; @@ -3033,14 +3033,14 @@ impl ScreenLike for TokensScreen { fn display_message(&mut self, msg: &str, msg_type: MessageType) { // Clear any active operation banner - if let Some(h) = self.operation_banner.take() { - h.clear(); - } + 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 - if msg_type == MessageType::Error && self.contract_details_loading { + // 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; } diff --git a/src/ui/tools/masternode_list_diff_screen.rs b/src/ui/tools/masternode_list_diff_screen.rs index 3ccc851f6..5e30a55b3 100644 --- a/src/ui/tools/masternode_list_diff_screen.rs +++ b/src/ui/tools/masternode_list_diff_screen.rs @@ -4,7 +4,6 @@ use crate::backend_task::mnlist::MnListTask; use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::components::core_p2p_handler::CoreP2PHandler; 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; @@ -4147,11 +4146,11 @@ impl MasternodeListDiffScreen { impl ScreenLike for MasternodeListDiffScreen { 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.task.pending = None; self.ui_state.error = Some(message.to_string()); } - MessageBanner::set_global(self.app_context.egui_ctx(), message, message_type); } fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) {