diff --git a/CLAUDE.md b/CLAUDE.md index 0bfa8aa2b..799f3a55b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,6 +43,8 @@ cross build --target x86_64-pc-windows-gnu --release - **Async Backend Tasks**: Communication via crossbeam channels with result handling - **Network Isolation**: Separate app contexts per network with independent databases - **Real-time Updates**: ZMQ listeners for core blockchain events on network-specific ports +- **Custom UI components**: we build a library of reusable widgets in `ui/components` whenever we need similar + widget displayed in more than 2 places ### Critical Dependencies - **dash-sdk**: Core Dash Platform SDK (git dependency, specific revision) diff --git a/doc/COMPONENT_DESIGN_PATTERN.md b/doc/COMPONENT_DESIGN_PATTERN.md index a81d3c94b..d1a6d73df 100644 --- a/doc/COMPONENT_DESIGN_PATTERN.md +++ b/doc/COMPONENT_DESIGN_PATTERN.md @@ -89,6 +89,7 @@ impl ComponentResponse for MyComponentResponse { - [ ] Keep internal state private - [ ] **Be self-contained**: Handle validation, error display, hints, and formatting internally (preferably with configurable error display) - [ ] **Own your UX**: Component should manage its complete user experience +- [ ] Colors should be defined in `ComponentStyles` and optimized for light and dark mode ### ❌ Anti-Patterns to Avoid - Public mutable fields diff --git a/src/ui/components/confirmation_dialog.rs b/src/ui/components/confirmation_dialog.rs new file mode 100644 index 000000000..6f04f2a2b --- /dev/null +++ b/src/ui/components/confirmation_dialog.rs @@ -0,0 +1,359 @@ +use std::sync::Arc; + +use crate::ui::components::component_trait::{Component, ComponentResponse}; +use crate::ui::theme::{ComponentStyles, DashColors, Shape}; +use egui::{InnerResponse, Ui, WidgetText}; + +/// Response from showing a confirmation dialog +#[derive(Debug, Clone, PartialEq)] +pub enum ConfirmationStatus { + /// User clicked confirm button + Confirmed, + /// User clicked cancel button or closed dialog + Canceled, +} + +pub const NOTHING: Option<&str> = None; +/// Response struct for the ConfirmationDialog component following the Component trait pattern +#[derive(Debug, Clone)] +pub struct ConfirmationDialogComponentResponse { + pub response: egui::Response, + pub changed: bool, + pub error_message: Option, + pub dialog_response: Option, +} + +impl ComponentResponse for ConfirmationDialogComponentResponse { + type DomainType = ConfirmationStatus; + + fn has_changed(&self) -> bool { + self.changed + } + + fn is_valid(&self) -> bool { + self.error_message.is_none() + } + + fn changed_value(&self) -> &Option { + if self.has_changed() { + &self.dialog_response + } else { + &None + } + } + + fn error_message(&self) -> Option<&str> { + self.error_message.as_deref() + } +} +/// A reusable confirmation dialog component that implements the Component trait +/// +/// This component provides a consistent modal dialog for confirming user actions +/// across the application. It supports customizable titles, messages, button text +/// with rich formatting (using WidgetText for styling), danger mode for destructive +/// actions, and optional buttons (confirm and cancel buttons can be hidden independently). +/// The dialog can be dismissed by pressing Escape (treated as cancel) or clicking the X button. +pub struct ConfirmationDialog { + title: WidgetText, + message: WidgetText, + status: Option, + confirm_text: Option, + cancel_text: Option, + danger_mode: bool, + is_open: bool, +} + +impl Component for ConfirmationDialog { + type DomainType = ConfirmationStatus; + type Response = ConfirmationDialogComponentResponse; + + fn show(&mut self, ui: &mut Ui) -> InnerResponse { + let inner_response = self.show_dialog(ui); + let changed = inner_response.inner.is_some(); + let response = inner_response.response; + + InnerResponse::new( + ConfirmationDialogComponentResponse { + response: response.clone(), + changed, + error_message: None, // Confirmation dialogs don't have validation errors + dialog_response: inner_response.inner, + }, + response, + ) + } + + fn current_value(&self) -> Option { + // Return the current dialog state - None if still open, Some(status) if closed + if self.is_open { + None + } else { + Some(ConfirmationStatus::Canceled) // If dialog is closed, it was canceled + } + } +} + +impl ConfirmationDialog { + /// Create a new confirmation dialog with the given title and message + pub fn new(title: impl Into, message: impl Into) -> Self { + Self { + title: title.into(), + message: message.into(), + confirm_text: Some("Confirm".into()), + cancel_text: Some("Cancel".into()), + danger_mode: false, + is_open: true, + status: None, // No action taken yet + } + } + + /// Set the text for the confirm button, or None to hide it + pub fn confirm_text(mut self, text: Option>) -> Self { + self.confirm_text = text.map(|t| t.into()); + self + } + + /// Set the text for the cancel button, or None to hide it + pub fn cancel_text(mut self, text: Option>) -> Self { + self.cancel_text = text.map(|t| t.into()); + self + } + + /// Enable danger mode (red confirm button) for destructive actions + pub fn danger_mode(mut self, enabled: bool) -> Self { + self.danger_mode = enabled; + self + } + + /// Set whether the dialog is open + pub fn open(mut self, open: bool) -> Self { + self.is_open = open; + self + } +} + +impl ConfirmationDialog { + /// Show the dialog and return the user's response + fn show_dialog(&mut self, ui: &mut Ui) -> InnerResponse> { + let mut is_open = self.is_open; + + if !is_open { + return InnerResponse::new( + None, // no change + ui.allocate_response(egui::Vec2::ZERO, egui::Sense::hover()), + ); + } + + // Draw dark overlay behind the dialog for better visibility + let screen_rect = ui.ctx().screen_rect(); + let painter = ui.ctx().layer_painter(egui::LayerId::new( + egui::Order::Background, + egui::Id::new("confirmation_dialog_overlay"), + )); + painter.rect_filled( + screen_rect, + 0.0, + egui::Color32::from_rgba_unmultiplied(0, 0, 0, 120), // Semi-transparent black overlay + ); + + let mut final_response = None; + let window_response = egui::Window::new(self.title.clone()) + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) + .open(&mut is_open) + .frame(egui::Frame { + inner_margin: egui::Margin::same(16), + outer_margin: egui::Margin::same(0), + corner_radius: egui::CornerRadius::same(8), + shadow: egui::epaint::Shadow { + offset: [0, 8], + blur: 16, + spread: 0, + color: egui::Color32::from_rgba_unmultiplied(0, 0, 0, 100), + }, + fill: ui.style().visuals.window_fill, + stroke: egui::Stroke::new( + 1.0, + egui::Color32::from_rgba_unmultiplied(255, 255, 255, 30), + ), + }) + .show(ui.ctx(), |ui| { + // Set minimum width for the dialog + ui.set_min_width(300.0); + + let dark_mode = ui.ctx().style().visuals.dark_mode; + + // Message content with bold text and proper color + ui.add_space(10.0); + ui.label( + egui::RichText::new(self.message.text()) + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + ui.add_space(20.0); + + // Buttons + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // Confirm button (only if text is provided) + if let Some(confirm_text) = &self.confirm_text { + let (fill_color, text_color) = if self.danger_mode { + ( + ComponentStyles::danger_button_fill(), + ComponentStyles::danger_button_text(), + ) + } else { + ( + ComponentStyles::primary_button_fill(), + ComponentStyles::primary_button_text(), + ) + }; + let confirm_label = if let WidgetText::RichText(rich_text) = + confirm_text + { + // preserve rich text formatting + rich_text.clone() + } else { + Arc::new(egui::RichText::new(confirm_text.text()).color(text_color)) + }; + + let confirm_button = egui::Button::new(confirm_label) + .fill(fill_color) + .stroke(if self.danger_mode { + egui::Stroke::NONE + } else { + ComponentStyles::primary_button_stroke() + }) + .corner_radius(egui::CornerRadius::same(Shape::RADIUS_SM)) + .min_size(egui::Vec2::new(80.0, 32.0)); + + if ui + .add(confirm_button) + .on_hover_cursor(egui::CursorIcon::PointingHand) + .clicked() + { + final_response = Some(ConfirmationStatus::Confirmed); + } + } + + // Cancel button (only if text is provided) + if let Some(cancel_text) = &self.cancel_text { + let cancel_label = if let WidgetText::RichText(rich_text) = cancel_text + { + // preserve rich text formatting + rich_text.clone() + } else { + egui::RichText::new(cancel_text.text()) + .color(ComponentStyles::secondary_button_text()) + .into() + }; + + let cancel_button = egui::Button::new(cancel_label) + .fill(ComponentStyles::secondary_button_fill()) + .stroke(ComponentStyles::secondary_button_stroke()) + .corner_radius(egui::CornerRadius::same(Shape::RADIUS_SM)) + .min_size(egui::Vec2::new(80.0, 32.0)); + + if ui + .add(cancel_button) + .on_hover_cursor(egui::CursorIcon::PointingHand) + .clicked() + { + final_response = Some(ConfirmationStatus::Canceled); + } + + ui.add_space(8.0); // Add spacing between buttons + } + }); + }); + }); + + // Handle window being closed via X button - treat as cancel + if !is_open && final_response.is_none() { + final_response = Some(ConfirmationStatus::Canceled); + } + + // Handle Escape key press - always treat as cancel + if final_response.is_none() && ui.input(|i| i.key_pressed(egui::Key::Escape)) { + final_response = Some(ConfirmationStatus::Canceled); + } + + // Update the dialog's state + self.is_open = is_open; + // if user actually did something, update the status + if final_response.is_some() { + self.status = final_response.clone(); + } + + if let Some(window_response) = window_response { + InnerResponse::new(final_response, window_response.response) + } else { + InnerResponse::new( + final_response, + ui.allocate_response(egui::Vec2::ZERO, egui::Sense::hover()), + ) + } + } +} +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_confirmation_dialog_creation() { + let dialog = ConfirmationDialog::new("Test Title", "Test Message") + .confirm_text(Some("Yes")) + .cancel_text(Some("No")) + .danger_mode(true); + + assert_eq!(dialog.title.text(), "Test Title"); + assert_eq!(dialog.message.text(), "Test Message"); + assert!(dialog.confirm_text.is_some_and(|t| t.text() == "Yes")); + assert!(dialog.cancel_text.is_some_and(|t| t.text() == "No")); + assert!(dialog.danger_mode); + assert!(dialog.is_open); + } + + #[test] + fn test_confirmation_dialog_no_buttons() { + let dialog = ConfirmationDialog::new("Test Title", "Test Message") + .confirm_text(NOTHING) + .cancel_text(NOTHING); + + assert_eq!(dialog.title.text(), "Test Title"); + assert_eq!(dialog.message.text(), "Test Message"); + assert!(dialog.confirm_text.is_none()); + assert!(dialog.cancel_text.is_none()); + assert!(!dialog.danger_mode); + assert!(dialog.is_open); + } + + #[test] + fn test_confirmation_dialog_only_confirm_button() { + let dialog = ConfirmationDialog::new("Test Title", "Test Message") + .confirm_text(Some("OK")) + .cancel_text(NOTHING); + + assert_eq!(dialog.title.text(), "Test Title"); + assert_eq!(dialog.message.text(), "Test Message"); + assert!(dialog.confirm_text.is_some()); + assert!(dialog.cancel_text.is_none()); + assert!(!dialog.danger_mode); + assert!(dialog.is_open); + } + + #[test] + fn test_confirmation_dialog_only_cancel_button() { + let dialog = ConfirmationDialog::new("Test Title", "Test Message") + .confirm_text(NOTHING) + .cancel_text(Some("Close")); + + assert_eq!(dialog.title.text(), "Test Title"); + assert_eq!(dialog.message.text(), "Test Message"); + assert!(dialog.confirm_text.is_none()); + assert!(dialog.cancel_text.is_some()); + assert!(!dialog.danger_mode); + assert!(dialog.is_open); + } +} diff --git a/src/ui/components/mod.rs b/src/ui/components/mod.rs index 94c579e59..e728d66a6 100644 --- a/src/ui/components/mod.rs +++ b/src/ui/components/mod.rs @@ -1,5 +1,6 @@ pub mod amount_input; pub mod component_trait; +pub mod confirmation_dialog; pub mod contract_chooser_panel; pub mod dpns_subscreen_chooser_panel; pub mod entropy_grid; diff --git a/src/ui/components/styled.rs b/src/ui/components/styled.rs index a9f5ac01e..ad83e5799 100644 --- a/src/ui/components/styled.rs +++ b/src/ui/components/styled.rs @@ -9,6 +9,9 @@ use egui::{ Ui, Vec2, }; +// Re-export commonly used components +pub use super::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; + /// Styled button variants #[allow(dead_code)] pub(crate) enum ButtonVariant { diff --git a/src/ui/identities/keys/key_info_screen.rs b/src/ui/identities/keys/key_info_screen.rs index 03d8da0dc..1c12968d8 100644 --- a/src/ui/identities/keys/key_info_screen.rs +++ b/src/ui/identities/keys/key_info_screen.rs @@ -303,9 +303,16 @@ impl ScreenLike for KeyInfoScreen { .num_columns(2) .spacing([10.0, 10.0]) .show(ui, |ui| { - ui.label(RichText::new("Private Key (Hex):").strong().color(ui.visuals().text_color())); + ui.label( + RichText::new("Private Key (Hex):") + .strong() + .color(ui.visuals().text_color()), + ); let private_key_hex = hex::encode(clear); - ui.label(RichText::new(private_key_hex).color(ui.visuals().text_color())); + ui.label( + RichText::new(private_key_hex) + .color(ui.visuals().text_color()), + ); ui.end_row(); }); ui.add_space(10.0); @@ -330,14 +337,29 @@ impl ScreenLike for KeyInfoScreen { .num_columns(2) .spacing([10.0, 10.0]) .show(ui, |ui| { - ui.label(RichText::new("Private Key (WIF):").strong().color(ui.visuals().text_color())); + ui.label( + RichText::new("Private Key (WIF):") + .strong() + .color(ui.visuals().text_color()), + ); let private_key_wif = private_key.to_wif(); - ui.label(RichText::new(private_key_wif).color(ui.visuals().text_color())); + ui.label( + RichText::new(private_key_wif) + .color(ui.visuals().text_color()), + ); ui.end_row(); - - ui.label(RichText::new("Private Key (Hex):").strong().color(ui.visuals().text_color())); - let private_key_hex = hex::encode(private_key.inner.secret_bytes()); - ui.label(RichText::new(private_key_hex).color(ui.visuals().text_color())); + + ui.label( + RichText::new("Private Key (Hex):") + .strong() + .color(ui.visuals().text_color()), + ); + let private_key_hex = + hex::encode(private_key.inner.secret_bytes()); + ui.label( + RichText::new(private_key_hex) + .color(ui.visuals().text_color()), + ); ui.end_row(); }); } else { @@ -352,17 +374,33 @@ impl ScreenLike for KeyInfoScreen { .num_columns(2) .spacing([10.0, 10.0]) .show(ui, |ui| { - ui.label(RichText::new("Private Key (WIF):").strong().color(ui.visuals().text_color())); + ui.label( + RichText::new("Private Key (WIF):") + .strong() + .color(ui.visuals().text_color()), + ); let private_key_wif = private_key.to_wif(); - ui.label(RichText::new(private_key_wif).color(ui.visuals().text_color())); + ui.label( + RichText::new(private_key_wif) + .color(ui.visuals().text_color()), + ); ui.end_row(); - - ui.label(RichText::new("Private Key (Hex):").strong().color(ui.visuals().text_color())); - let private_key_hex = hex::encode(private_key.inner.secret_bytes()); - ui.label(RichText::new(private_key_hex).color(ui.visuals().text_color())); + + ui.label( + RichText::new("Private Key (Hex):") + .strong() + .color(ui.visuals().text_color()), + ); + let private_key_hex = hex::encode( + private_key.inner.secret_bytes(), + ); + ui.label( + RichText::new(private_key_hex) + .color(ui.visuals().text_color()), + ); ui.end_row(); }); - + self.decrypted_private_key = Some(private_key); } Err(e) => { @@ -392,17 +430,33 @@ impl ScreenLike for KeyInfoScreen { .num_columns(2) .spacing([10.0, 10.0]) .show(ui, |ui| { - ui.label(RichText::new("Private Key (WIF):").strong().color(ui.visuals().text_color())); + ui.label( + RichText::new("Private Key (WIF):") + .strong() + .color(ui.visuals().text_color()), + ); let private_key_wif = private_key.to_wif(); - ui.label(RichText::new(private_key_wif).color(ui.visuals().text_color())); + ui.label( + RichText::new(private_key_wif) + .color(ui.visuals().text_color()), + ); ui.end_row(); - - ui.label(RichText::new("Private Key (Hex):").strong().color(ui.visuals().text_color())); - let private_key_hex = hex::encode(private_key.inner.secret_bytes()); - ui.label(RichText::new(private_key_hex).color(ui.visuals().text_color())); + + ui.label( + RichText::new("Private Key (Hex):") + .strong() + .color(ui.visuals().text_color()), + ); + let private_key_hex = hex::encode( + private_key.inner.secret_bytes(), + ); + ui.label( + RichText::new(private_key_hex) + .color(ui.visuals().text_color()), + ); ui.end_row(); }); - + self.decrypted_private_key = Some(private_key); } Err(e) => { diff --git a/src/ui/identities/transfer_screen.rs b/src/ui/identities/transfer_screen.rs index 31d13e60f..5596766e2 100644 --- a/src/ui/identities/transfer_screen.rs +++ b/src/ui/identities/transfer_screen.rs @@ -6,11 +6,12 @@ use crate::model::amount::Amount; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; use crate::ui::components::amount_input::AmountInput; +use crate::ui::components::component_trait::{Component, ComponentResponse}; +use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; 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::{Component, ComponentResponse}; use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; use crate::ui::{MessageType, Screen, ScreenLike}; use dash_sdk::dpp::fee::Credits; @@ -51,6 +52,7 @@ pub struct TransferScreen { max_amount: u64, pub app_context: Arc, confirmation_popup: bool, + confirmation_dialog: Option, selected_wallet: Option>>, wallet_password: String, show_password: bool, @@ -85,6 +87,7 @@ impl TransferScreen { max_amount, app_context: app_context.clone(), confirmation_popup: false, + confirmation_dialog: None, selected_wallet, wallet_password: String::new(), show_password: false, @@ -148,84 +151,109 @@ impl TransferScreen { ); } - fn show_confirmation_popup(&mut self, ui: &mut Ui) -> AppAction { - let mut app_action = AppAction::None; - let mut is_open = true; - egui::Window::new("Confirm Transfer") - .collapsible(false) - .open(&mut is_open) - .show(ui.ctx(), |ui| { - let identifier = if self.receiver_identity_id.is_empty() { - self.error_message = Some("Invalid identifier".to_string()); - self.transfer_credits_status = - TransferCreditsStatus::ErrorMessage("Invalid identifier".to_string()); - self.confirmation_popup = false; - return; - } else { - match Identifier::from_string_try_encodings( - &self.receiver_identity_id, - &[Encoding::Base58, Encoding::Hex], - ) { - Ok(identifier) => identifier, - Err(_) => { - self.error_message = Some("Invalid identifier".to_string()); - self.transfer_credits_status = TransferCreditsStatus::ErrorMessage( - "Invalid identifier".to_string(), - ); - self.confirmation_popup = false; - return; - } - } - }; - - let Some(selected_key) = self.selected_key.as_ref() else { - self.error_message = Some("No selected key".to_string()); - self.transfer_credits_status = - TransferCreditsStatus::ErrorMessage("No selected key".to_string()); - self.confirmation_popup = false; - return; - }; - - ui.label(format!( - "Are you sure you want to transfer {} to {}", - self.amount.as_ref().expect("Amount should be present"), - self.receiver_identity_id - )); - - // 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.confirmation_popup = false; - return; - } + /// Handle the confirmation action when user clicks OK + fn confirmation_ok(&mut self) -> AppAction { + self.confirmation_popup = false; + self.confirmation_dialog = None; // Reset the dialog for next use + + // Validate identifier + let identifier = match self.validate_receiver_identifier() { + Ok(id) => id, + Err(error) => { + self.set_error_state(error); + return AppAction::None; + } + }; - if ui.button("Confirm").clicked() { - self.confirmation_popup = false; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .as_secs(); - self.transfer_credits_status = TransferCreditsStatus::WaitingForResult(now); - app_action = - AppAction::BackendTask(BackendTask::IdentityTask(IdentityTask::Transfer( - self.identity.clone(), - identifier, - credits as Credits, - Some(selected_key.id()), - ))); - } - if ui.button("Cancel").clicked() { - self.confirmation_popup = false; - } - }); - if !is_open { + // Validate selected key + let selected_key = match self.selected_key.as_ref() { + Some(key) => key, + None => { + self.set_error_state("No selected key".to_string()); + return AppAction::None; + } + }; + + // 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.confirmation_popup = false; + return AppAction::None; + } + + // 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); + + AppAction::BackendTask(BackendTask::IdentityTask(IdentityTask::Transfer( + self.identity.clone(), + identifier, + credits as Credits, + Some(selected_key.id()), + ))) + } + + /// Handle the cancel action when user clicks Cancel or closes dialog + fn confirmation_cancel(&mut self) -> AppAction { + self.confirmation_popup = false; + self.confirmation_dialog = None; // Reset the dialog for next use + AppAction::None + } + + /// Validate the receiver identity identifier + fn validate_receiver_identifier(&self) -> Result { + if self.receiver_identity_id.is_empty() { + return Err("Invalid identifier".to_string()); + } + + Identifier::from_string_try_encodings( + &self.receiver_identity_id, + &[Encoding::Base58, Encoding::Hex], + ) + .map_err(|_| "Invalid identifier".to_string()) + } + + /// 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); + } + + fn show_confirmation_popup(&mut self, ui: &mut Ui) -> AppAction { + // Prepare values before borrowing + let Some(amount) = &self.amount else { + self.set_error_state("Incorrect or empty amount".to_string()); + return AppAction::None; + }; + + let receiver_id = self.receiver_identity_id.clone(); + + let msg = format!( + "Are you sure you want to transfer {} to {}?", + amount, receiver_id + ); + + // Lazy initialization of the confirmation dialog + let confirmation_dialog = self.confirmation_dialog.get_or_insert_with(|| { + ConfirmationDialog::new("Confirm Transfer", msg) + .confirm_text(Some("Confirm")) + .cancel_text(Some("Cancel")) + }); + + let response = confirmation_dialog.show(ui); + + // Handle the response using the Component pattern + match response.inner.dialog_response { + Some(ConfirmationStatus::Confirmed) => self.confirmation_ok(), + Some(ConfirmationStatus::Canceled) => self.confirmation_cancel(), + None => AppAction::None, } - app_action } pub fn show_success(&self, ui: &mut Ui) -> AppAction { @@ -401,7 +429,11 @@ impl ScreenLike for TransferScreen { // Transfer button let ready = self.amount.is_some() && !self.receiver_identity_id.is_empty() - && self.selected_key.is_some(); + && self.selected_key.is_some() + && !matches!( + self.transfer_credits_status, + TransferCreditsStatus::WaitingForResult(_), + ); let mut new_style = (**ui.style()).clone(); new_style.spacing.button_padding = egui::vec2(10.0, 5.0); ui.set_style(new_style); diff --git a/src/ui/theme.rs b/src/ui/theme.rs index 4b1485f99..4b2be45d8 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -453,6 +453,10 @@ impl ComponentStyles { DashColors::WHITE } + pub fn primary_button_stroke() -> Stroke { + Stroke::new(1.0, DashColors::DASH_BLUE) + } + pub fn secondary_button_fill() -> Color32 { DashColors::WHITE } diff --git a/src/ui/tokens/set_token_price_screen.rs b/src/ui/tokens/set_token_price_screen.rs index 374071c9b..f3e42a24d 100644 --- a/src/ui/tokens/set_token_price_screen.rs +++ b/src/ui/tokens/set_token_price_screen.rs @@ -4,6 +4,8 @@ use crate::backend_task::BackendTask; use crate::backend_task::tokens::TokenTask; use crate::context::AppContext; use crate::model::wallet::Wallet; +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::styled::island_central_panel; use crate::ui::components::tokens_subscreen_chooser_panel::add_tokens_subscreen_chooser_panel; @@ -74,6 +76,7 @@ pub struct SetTokenPriceScreen { /// Confirmation popup show_confirmation_popup: bool, + confirmation_dialog: Option, // If needed for password-based wallet unlocking: selected_wallet: Option>>, @@ -206,6 +209,7 @@ impl SetTokenPriceScreen { error_message: None, app_context: app_context.clone(), show_confirmation_popup: false, + confirmation_dialog: None, selected_wallet, wallet_password: String::new(), show_password: false, @@ -489,127 +493,195 @@ impl SetTokenPriceScreen { } } - /// Renders a confirm popup with the final "Are you sure?" step - fn show_confirmation_popup(&mut self, ui: &mut Ui) -> AppAction { - let mut action = AppAction::None; - let mut is_open = true; - egui::Window::new("Confirm SetPricingSchedule") - .collapsible(false) - .open(&mut is_open) - .frame( - egui::Frame::default() - .fill(Color32::from_rgb(245, 245, 245)) - .stroke(egui::Stroke::new(1.0, Color32::from_rgb(200, 200, 200))) - .shadow(egui::epaint::Shadow::default()) - .inner_margin(egui::Margin::same(20)) - .corner_radius(egui::CornerRadius::same(8)), - ) - .show(ui.ctx(), |ui| { - // Validate user input - let token_pricing_schedule_opt = match self.create_pricing_schedule() { - Ok(schedule) => schedule, - Err(error) => { - self.error_message = Some(error.clone()); - self.status = SetTokenPriceStatus::ErrorMessage(error); - self.show_confirmation_popup = false; - return; + /// Validate the current pricing configuration before showing confirmation dialog + fn validate_pricing_configuration(&self) -> Result<(), String> { + match self.pricing_type { + PricingType::RemovePricing => Ok(()), + PricingType::SinglePrice => { + if self.single_price.trim().is_empty() { + return Err("Please enter a price".to_string()); + } + match self.single_price.trim().parse::() { + Ok(price) if price > 0.0 => Ok(()), + Ok(_) => Err("Price must be greater than 0".to_string()), + Err(_) => Err("Invalid price format - must be a positive number".to_string()), + } + } + PricingType::TieredPricing => { + let mut valid_tiers = 0; + + for (amount_str, price_str) in &self.tiered_prices { + if amount_str.trim().is_empty() || price_str.trim().is_empty() { + continue; } - }; - // Show confirmation message based on pricing type - match &self.pricing_type { - PricingType::RemovePricing => { - ui.colored_label( - Color32::from_rgb(180, 100, 0), - "WARNING: Are you sure you want to remove the pricing schedule?", - ); - ui.label("This will make the token unavailable for direct purchase."); + let _amount = amount_str.trim().parse::().map_err(|_| { + format!( + "Invalid amount '{}' - must be a whole number", + amount_str.trim() + ) + })?; + + let price = price_str.trim().parse::().map_err(|_| { + format!( + "Invalid price '{}' - must be a positive number", + price_str.trim() + ) + })?; + + if price <= 0.0 { + return Err(format!( + "Price '{}' must be greater than 0", + price_str.trim() + )); } - PricingType::SinglePrice => { - if let Ok(dash_price) = self.single_price.trim().parse::() { - ui.label(format!( - "Are you sure you want to set a fixed price of {} Dash per token?", - dash_price - )); - } + + valid_tiers += 1; + } + + if valid_tiers == 0 { + return Err("Please add at least one valid pricing tier".to_string()); + } + + Ok(()) + } + } + } + + /// Generate the confirmation message for the set price dialog + /// + /// ## Panics + /// + /// Panics if the pricing type is not set correctly or if the single price is not a valid number. + fn confirmation_message(&self) -> String { + match &self.pricing_type { + PricingType::RemovePricing => { + "WARNING: Are you sure you want to remove the pricing schedule? This will make the token unavailable for direct purchase.".to_string() + } + PricingType::SinglePrice => { + if let Ok(dash_price) = self.single_price.trim().parse::() { + format!( + "Are you sure you want to set a fixed price of {} Dash per token?", + dash_price + ) + } else { + "Are you sure you want to set the pricing schedule?".to_string() + } + } + PricingType::TieredPricing => { + let mut message = "Are you sure you want to set the following tiered pricing?".to_string(); + for (amount_str, price_str) in &self.tiered_prices { + if amount_str.trim().is_empty() || price_str.trim().is_empty() { + continue; } - PricingType::TieredPricing => { - ui.label("Are you sure you want to set the following tiered pricing?"); - ui.add_space(5.0); - for (amount_str, price_str) in &self.tiered_prices { - if amount_str.trim().is_empty() || price_str.trim().is_empty() { - continue; - } - if let (Ok(amount), Ok(dash_price)) = ( - amount_str.trim().parse::(), - price_str.trim().parse::(), - ) { - ui.label(format!( - " - {} or more tokens: {} Dash each", - amount, dash_price - )); - } - } + if let (Ok(amount), Ok(dash_price)) = ( + amount_str.trim().parse::(), + price_str.trim().parse::(), + ) { + message.push_str(&format!( + " + - {} or more tokens: {} Dash each", + amount, dash_price + )); } } + message + } + } + } - ui.add_space(10.0); + /// Handle the confirmation action when user clicks OK + fn confirmation_ok(&mut self) -> AppAction { + self.show_confirmation_popup = false; + self.confirmation_dialog = None; // Reset the dialog for next use + + // Validate user input and create pricing schedule + let token_pricing_schedule_opt = match self.create_pricing_schedule() { + Ok(schedule) => schedule, + Err(error) => { + // This should not happen if validation was done before opening dialog, + // but we handle it as a safety net + self.set_error_state(format!("Validation error: {}", error)); + return AppAction::None; + } + }; - // Confirm button - if ui.button("Confirm").clicked() { - self.show_confirmation_popup = false; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .as_secs(); - self.status = SetTokenPriceStatus::WaitingForResult(now); - - let group_info = if self.group_action_id.is_some() { - self.group.as_ref().map(|(pos, _)| { - GroupStateTransitionInfoStatus::GroupStateTransitionInfoOtherSigner( - GroupStateTransitionInfo { - group_contract_position: *pos, - action_id: self.group_action_id.unwrap(), - action_is_proposer: false, - }, - ) - }) - } else { - self.group.as_ref().map(|(pos, _)| { - GroupStateTransitionInfoStatus::GroupStateTransitionInfoProposer(*pos) - }) - }; - - // Dispatch the actual backend mint action - action = 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"), - public_note: if self.group_action_id.is_some() { - None - } else { - self.public_note.clone() - }, - token_pricing_schedule: token_pricing_schedule_opt, - group_info, - }, - ))); - } + // Set waiting state + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(); + self.status = SetTokenPriceStatus::WaitingForResult(now); + + // Prepare group info + let group_info = if self.group_action_id.is_some() { + self.group.as_ref().map(|(pos, _)| { + GroupStateTransitionInfoStatus::GroupStateTransitionInfoOtherSigner( + GroupStateTransitionInfo { + group_contract_position: *pos, + action_id: self.group_action_id.unwrap(), + action_is_proposer: false, + }, + ) + }) + } else { + self.group.as_ref().map(|(pos, _)| { + GroupStateTransitionInfoStatus::GroupStateTransitionInfoProposer(*pos) + }) + }; - // Cancel button - if ui.button("Cancel").clicked() { - self.show_confirmation_popup = false; - } - }); + // 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"), + public_note: if self.group_action_id.is_some() { + None + } else { + self.public_note.clone() + }, + token_pricing_schedule: token_pricing_schedule_opt, + group_info, + }, + ))) + } + + /// Handle the cancel action when user clicks Cancel or closes dialog + fn confirmation_cancel(&mut self) -> AppAction { + self.show_confirmation_popup = false; + self.confirmation_dialog = None; // Reset the dialog for next use + AppAction::None + } + + /// 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); + } - if !is_open { - self.show_confirmation_popup = false; + /// Renders a confirm popup with the final "Are you sure?" step + fn show_confirmation_popup(&mut self, ui: &mut Ui) -> AppAction { + // Prepare values before borrowing + let confirmation_message = self.confirmation_message(); + let is_danger_mode = self.pricing_type == PricingType::RemovePricing; + + // Lazy initialization of the confirmation dialog + let confirmation_dialog = self.confirmation_dialog.get_or_insert_with(|| { + ConfirmationDialog::new("Confirm pricing schedule update", confirmation_message) + .confirm_text(Some("Confirm")) + .cancel_text(Some("Cancel")) + .danger_mode(is_danger_mode) + }); + + let response = confirmation_dialog.show(ui); + + match response.inner.dialog_response { + Some(ConfirmationStatus::Confirmed) => self.confirmation_ok(), + Some(ConfirmationStatus::Canceled) => self.confirmation_cancel(), + None => AppAction::None, } - action } /// Renders a simple "Success!" screen after completion @@ -911,25 +983,10 @@ impl ScreenLike for SetTokenPriceScreen { }; // Set price button - let can_proceed = match self.pricing_type { - PricingType::RemovePricing => true, - PricingType::SinglePrice => { - if let Ok(price) = self.single_price.trim().parse::() { - price > 0.0 - } else { - false - } - }, - PricingType::TieredPricing => { - self.tiered_prices.iter().any(|(amount, price)| { - !amount.trim().is_empty() && !price.trim().is_empty() && - amount.trim().parse::().is_ok() && - if let Ok(p) = price.trim().parse::() { p > 0.0 } else { false } - }) - } - }; + let validation_result = self.validate_pricing_configuration(); + let button_active = validation_result.is_ok() && !matches!(self.status, SetTokenPriceStatus::WaitingForResult(_)); - let button_color = if can_proceed { + let button_color = if validation_result.is_ok() { Color32::from_rgb(0, 128, 255) } else { Color32::from_rgb(100, 100, 100) @@ -939,10 +996,10 @@ impl ScreenLike for SetTokenPriceScreen { .fill(button_color) .corner_radius(3.0); - let button_response = ui.add_enabled(can_proceed, button); + let button_response = ui.add_enabled(button_active, button); - if !can_proceed { - button_response.on_hover_text("Please enter valid pricing information"); + if let Err(hover_message) = validation_result { + button_response.on_disabled_hover_text(hover_message); } else if button_response.clicked() { self.show_confirmation_popup = true; }