diff --git a/src/backend_task/system_task/mod.rs b/src/backend_task/system_task/mod.rs index d7a6383d2..2999b43fb 100644 --- a/src/backend_task/system_task/mod.rs +++ b/src/backend_task/system_task/mod.rs @@ -49,7 +49,7 @@ impl AppContext { theme_mode: ThemeMode, ) -> Result { let _guard = self.invalidate_settings_cache(); - + self.db .update_theme_preference(theme_mode) .map_err(|e| e.to_string())?; diff --git a/src/model/amount.rs b/src/model/amount.rs index dfa8b26bb..8230f7c9b 100644 --- a/src/model/amount.rs +++ b/src/model/amount.rs @@ -50,13 +50,11 @@ impl PartialEq for &Amount { impl Display for Amount { /// Formats the TokenValue as a user-friendly string with optional unit name. + /// + /// See [`Amount::to_string_opts()`] for more formatting options. fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let amount_str = self.to_string_without_unit(); - - match &self.unit_name { - Some(unit) => write!(f, "{} {}", amount_str, unit), - None => write!(f, "{}", amount_str), - } + let amount_str = self.to_string_opts(true, true); + write!(f, "{}", amount_str) } } @@ -88,8 +86,8 @@ impl Amount { /// This extracts the decimal places and token alias from the token configuration /// and creates an Amount with the specified value. pub fn from_token( - value: TokenAmount, token_info: &crate::ui::tokens::tokens_screen::IdentityTokenInfo, + value: TokenAmount, ) -> Self { let decimal_places = token_info.token_config.conventions().decimals(); Self::new(value, decimal_places).with_unit_name(&token_info.token_alias) @@ -221,7 +219,12 @@ impl Amount { /// Sets the unit name. pub fn with_unit_name(mut self, unit_name: &str) -> Self { - self.unit_name = Some(unit_name.to_string()); + if unit_name.is_empty() { + self.unit_name = None; + } else { + self.unit_name = Some(unit_name.to_string()); + } + self } @@ -232,25 +235,53 @@ impl Amount { } /// Returns the numeric string representation without the unit name. + /// Trailing zeroes are trimmed by default. /// This is useful for text input fields where only the number should be shown. + /// + /// ## See also + /// + /// [`Amount::to_string_opts()`] for more formatting options. pub fn to_string_without_unit(&self) -> String { - if self.decimal_places == 0 { - self.value.to_string() - } else { - let divisor = 10u64.pow(self.decimal_places as u32); - let whole = self.value / divisor; - let fraction = self.value % divisor; - - if fraction == 0 { - whole.to_string() - } else { - // Format with the appropriate number of decimal places, removing trailing zeros - let fraction_str = - format!("{:0width$}", fraction, width = self.decimal_places as usize); - let trimmed = fraction_str.trim_end_matches('0'); - format!("{}.{}", whole, trimmed) + self.to_string_opts(false, true) + } + + /// Formats the Amount as a string with options for unit display and trailing zeroes. + pub fn to_string_opts(&self, show_unit: bool, trim_trailing_zeroes: bool) -> String { + let mut result = String::new(); + + let divisor = 10u64.pow(self.decimal_places as u32); + let whole = self.value / divisor; + let fraction = self.value % divisor; + + // "123" + result.push_str(&whole.to_string()); + + if self.decimal_places != 0 { + // "123.0000" + result.push_str(&format!( + ".{:0width$}", + fraction, + width = self.decimal_places as usize + )); + + if trim_trailing_zeroes { + // Remove trailing zeros + // "123." + result = result.trim_end_matches('0').to_string(); } + // "123" + result = result.trim_end_matches('.').to_string(); + }; + + if show_unit + && let Some(unit_name) = self.unit_name.as_ref() + && !unit_name.is_empty() + { + result.push(' '); + result.push_str(unit_name); } + + result } /// Creates a new Amount with the specified value in TokenAmount. @@ -259,29 +290,6 @@ impl Amount { self } - /// Updates the decimal places for this amount. - /// This adjusts the internal value to maintain the same displayed amount. - /// - /// If new decimal places are equal to the current ones, it does nothing. - pub fn recalculate_decimal_places(mut self, new_decimal_places: u8) -> Self { - if self.decimal_places != new_decimal_places { - let current_decimals = self.decimal_places; - - if new_decimal_places > current_decimals { - // More decimal places - multiply value - let factor = 10u64.pow((new_decimal_places - current_decimals) as u32); - self.value = self.value.saturating_mul(factor); - } else if new_decimal_places < current_decimals { - // Fewer decimal places - divide value - let factor = 10u64.pow((current_decimals - new_decimal_places) as u32); - self.value /= factor; - } - - self.decimal_places = new_decimal_places; - } - self - } - /// Checks if the amount is for the same token as the other amount. /// /// This is determined by comparing the unit names and decimal places. @@ -668,25 +676,77 @@ mod tests { } #[test] - fn test_decimal_places_conversion() { - // Test converting from 2 decimal places to 8 decimal places - let amount = Amount::new(12345, 2); // 123.45 - let converted = amount.recalculate_decimal_places(8); - assert_eq!(converted.value(), 12345000000); // 123.45 with 8 decimals - assert_eq!(converted.decimal_places(), 8); - assert_eq!(format!("{}", converted), "123.45"); - - // Test converting from 8 decimal places to 2 decimal places - let amount = Amount::new(12345000000, 8); // 123.45 - let converted = amount.recalculate_decimal_places(2); - assert_eq!(converted.value(), 12345); // 123.45 with 2 decimals - assert_eq!(converted.decimal_places(), 2); - assert_eq!(format!("{}", converted), "123.45"); - - // Test no conversion (same decimal places) - let amount = Amount::new(12345, 2); - let same = amount.clone().recalculate_decimal_places(2); - assert_eq!(same.value(), 12345); - assert_eq!(same.decimal_places(), 2); + fn test_to_string_opts() { + // Test basic formatting options with 2 decimal places + let amount = Amount::new(12345, 2).with_unit_name("USD"); + + // Test all combinations of show_unit and trim_trailing_zeroes + assert_eq!(amount.to_string_opts(true, true), "123.45 USD"); // show unit, trim zeros + assert_eq!(amount.to_string_opts(false, true), "123.45"); // no unit, trim zeros + assert_eq!(amount.to_string_opts(true, false), "123.45 USD"); // show unit, no trim (same as above since no trailing zeros) + assert_eq!(amount.to_string_opts(false, false), "123.45"); // no unit, no trim (same as above since no trailing zeros) + + // Test with trailing zeros + let amount_with_zeros = Amount::new(12300, 2).with_unit_name("USD"); + assert_eq!(amount_with_zeros.to_string_opts(true, true), "123 USD"); // show unit, trim zeros + assert_eq!(amount_with_zeros.to_string_opts(false, true), "123"); // no unit, trim zeros + assert_eq!(amount_with_zeros.to_string_opts(true, false), "123.00 USD"); // show unit, no trim + assert_eq!(amount_with_zeros.to_string_opts(false, false), "123.00"); // no unit, no trim + + // Test with partial trailing zeros + let amount_partial_zeros = Amount::new(12340, 2).with_unit_name("USD"); + assert_eq!(amount_partial_zeros.to_string_opts(true, true), "123.4 USD"); // show unit, trim zeros + assert_eq!(amount_partial_zeros.to_string_opts(false, true), "123.4"); // no unit, trim zeros + assert_eq!( + amount_partial_zeros.to_string_opts(true, false), + "123.40 USD" + ); // show unit, no trim + assert_eq!(amount_partial_zeros.to_string_opts(false, false), "123.40"); // no unit, no trim + + // Test with 0 decimal places + let whole_amount = Amount::new(123, 0).with_unit_name("WHOLE"); + assert_eq!(whole_amount.to_string_opts(true, true), "123 WHOLE"); + assert_eq!(whole_amount.to_string_opts(false, true), "123"); + assert_eq!(whole_amount.to_string_opts(true, false), "123 WHOLE"); + assert_eq!(whole_amount.to_string_opts(false, false), "123"); + + // Test with high decimal places + let high_precision = Amount::new(123456789, 8).with_unit_name("BTC"); + assert_eq!(high_precision.to_string_opts(true, true), "1.23456789 BTC"); // trim zeros + assert_eq!(high_precision.to_string_opts(false, true), "1.23456789"); // trim zeros + assert_eq!(high_precision.to_string_opts(true, false), "1.23456789 BTC"); // no trim (same as above since no trailing zeros) + assert_eq!(high_precision.to_string_opts(false, false), "1.23456789"); // no trim (same as above since no trailing zeros) + + // Test with high decimal places and trailing zeros + let high_precision_zeros = Amount::new(100000000, 8).with_unit_name("BTC"); + assert_eq!(high_precision_zeros.to_string_opts(true, true), "1 BTC"); // trim zeros + assert_eq!(high_precision_zeros.to_string_opts(false, true), "1"); // trim zeros + assert_eq!( + high_precision_zeros.to_string_opts(true, false), + "1.00000000 BTC" + ); // no trim + assert_eq!( + high_precision_zeros.to_string_opts(false, false), + "1.00000000" + ); // no trim + + // Test zero amount + let zero_amount = Amount::new(0, 4).with_unit_name("TOKEN"); + assert_eq!(zero_amount.to_string_opts(true, true), "0 TOKEN"); + assert_eq!(zero_amount.to_string_opts(false, true), "0"); + assert_eq!(zero_amount.to_string_opts(true, false), "0.0000 TOKEN"); + assert_eq!(zero_amount.to_string_opts(false, false), "0.0000"); + + // Test amount without unit name + let no_unit = Amount::new(12345, 3); + assert_eq!(no_unit.to_string_opts(true, true), "12.345"); // show_unit=true but no unit name + assert_eq!(no_unit.to_string_opts(false, true), "12.345"); // show_unit=false + assert_eq!(no_unit.to_string_opts(true, false), "12.345"); // show_unit=true but no unit name, no trim + assert_eq!(no_unit.to_string_opts(false, false), "12.345"); // show_unit=false, no trim + + // Test amount with empty unit name (should be treated as no unit) + let empty_unit = Amount::new(12345, 2).with_unit_name(""); + assert_eq!(empty_unit.to_string_opts(true, true), "123.45"); // empty unit name should not show + assert_eq!(empty_unit.to_string_opts(false, true), "123.45"); } } diff --git a/src/ui/components/amount_input.rs b/src/ui/components/amount_input.rs index 8ef235d44..da497541f 100644 --- a/src/ui/components/amount_input.rs +++ b/src/ui/components/amount_input.rs @@ -118,7 +118,7 @@ impl AmountInput { show_max_button: false, desired_width: None, show_validation_errors: true, // Default to showing validation errors - changed: false, + changed: true, // Start as changed to force initial validation } } @@ -135,13 +135,36 @@ impl AmountInput { self.decimal_places } + /// Update decimal places used to render values. + /// + /// Value displayed in the input is not changed, but the actual [Amount] + /// will be multiplied or divided by 10^(difference of decimal places). + /// + /// ## Example + /// + /// The input contains `12.34` and decimal places is set to 3. + /// It will be interpreted as `12.340` when parsed (credits value `12_340`). + /// + /// + /// If you change the decimal places from 3 to 5: + /// + /// * The input will still display `12.34` (unchanged) + /// * The next time the input is parsed, it will generate `12.34000` + /// (credits value `1_234_000`). + pub fn set_decimal_places(&mut self, decimal_places: u8) -> &mut Self { + self.decimal_places = decimal_places; + self.changed = true; + + self + } + /// Gets the unit name this input is configured for. pub fn unit_name(&self) -> Option<&str> { self.unit_name.as_deref() } /// Sets the label for the input field. - pub fn label>(mut self, label: T) -> Self { + pub fn with_label>(mut self, label: T) -> Self { self.label = Some(label.into()); self } @@ -154,7 +177,7 @@ impl AmountInput { } /// Sets the hint text for the input field. - pub fn hint_text>(mut self, hint_text: T) -> Self { + pub fn with_hint_text>(mut self, hint_text: T) -> Self { self.hint_text = Some(hint_text.into()); self } @@ -167,7 +190,7 @@ impl AmountInput { /// Sets the maximum amount allowed. If provided, a "Max" button will be shown /// when `show_max_button` is true. - pub fn max_amount(mut self, max_amount: Option) -> Self { + pub fn with_max_amount(mut self, max_amount: Option) -> Self { self.max_amount = max_amount; self } @@ -181,7 +204,7 @@ impl AmountInput { /// Sets the minimum amount allowed. Defaults to 1 (must be greater than zero). /// Set to Some(0) to allow zero amounts, or None to disable minimum validation. - pub fn min_amount(mut self, min_amount: Option) -> Self { + pub fn with_min_amount(mut self, min_amount: Option) -> Self { self.min_amount = min_amount; self } @@ -193,7 +216,7 @@ impl AmountInput { } /// Whether to show a "Max" button that sets the amount to the maximum. - pub fn max_button(mut self, show: bool) -> Self { + pub fn with_max_button(mut self, show: bool) -> Self { self.show_max_button = show; self } @@ -205,7 +228,7 @@ impl AmountInput { } /// Sets the desired width of the input field. - pub fn desired_width(mut self, width: f32) -> Self { + pub fn with_desired_width(mut self, width: f32) -> Self { self.desired_width = Some(width); self } @@ -343,6 +366,15 @@ impl Component for AmountInput { fn show(&mut self, ui: &mut Ui) -> InnerResponse { AmountInput::show_internal(self, ui) } + + fn current_value(&self) -> Option { + // Validate the current amount string and return the parsed amount + match self.validate_amount() { + Ok(Some(amount)) => Some(amount), + Ok(None) => None, // Empty input + Err(_) => None, // Invalid input returns None + } + } } #[cfg(test)] @@ -382,15 +414,15 @@ mod tests { assert_eq!(input.min_amount, Some(1)); // Custom minimum - let input = AmountInput::new(Amount::new(0, 8)).min_amount(Some(1000)); + let input = AmountInput::new(Amount::new(0, 8)).with_min_amount(Some(1000)); assert_eq!(input.min_amount, Some(1000)); // Allow zero - let input = AmountInput::new(Amount::new(0, 8)).min_amount(Some(0)); + let input = AmountInput::new(Amount::new(0, 8)).with_min_amount(Some(0)); assert_eq!(input.min_amount, Some(0)); // No minimum - let input = AmountInput::new(Amount::new(0, 8)).min_amount(None); + let input = AmountInput::new(Amount::new(0, 8)).with_min_amount(None); assert_eq!(input.min_amount, None); } @@ -478,8 +510,8 @@ mod tests { fn test_min_max_validation() { let amount = Amount::new(0, 2); let mut input = AmountInput::new(amount) - .min_amount(Some(100)) // Minimum 1.00 - .max_amount(Some(10000)); // Maximum 100.00 + .with_min_amount(Some(100)) // Minimum 1.00 + .with_max_amount(Some(10000)); // Maximum 100.00 // Test amount below minimum input.amount_str = "0.50".to_string(); // 50 (below min of 100) diff --git a/src/ui/components/component_trait.rs b/src/ui/components/component_trait.rs index 199d87e2c..6a5feb28e 100644 --- a/src/ui/components/component_trait.rs +++ b/src/ui/components/component_trait.rs @@ -92,4 +92,12 @@ pub trait Component { /// An [`InnerResponse`] containing the component's response data in [`InnerResponse::inner`] field. /// [`InnerResponse::inner`] should implement [`ComponentResponse`] trait. fn show(&mut self, ui: &mut Ui) -> InnerResponse; + + /// Returns the current value of the component. + /// + /// Note that only valid values should be returned here. + /// If the component value is invalid, this should return `None`. + /// + /// See [`ComponentResponse::current_value`] for more details. + fn current_value(&self) -> Option; } diff --git a/src/ui/contracts_documents/group_actions_screen.rs b/src/ui/contracts_documents/group_actions_screen.rs index 74aea7cc8..3418eb419 100644 --- a/src/ui/contracts_documents/group_actions_screen.rs +++ b/src/ui/contracts_documents/group_actions_screen.rs @@ -12,6 +12,7 @@ use crate::app::AppAction; use crate::backend_task::contract::ContractTask; use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::context::AppContext; +use crate::model::amount::Amount; use crate::model::qualified_contract::QualifiedContract; use crate::model::qualified_identity::QualifiedIdentity; use crate::ui::components::identity_selector::IdentitySelector; @@ -355,14 +356,22 @@ 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); - mint_screen.amount_to_mint = amount.to_string(); + // Convert amount to Amount struct using the token configuration + mint_screen.amount = Some(Amount::from_token( + &mint_screen.identity_token_info, + *amount, + )); mint_screen.public_note = note_opt.clone(); *action |= AppAction::AddScreen(Screen::MintTokensScreen(mint_screen)); } TokenEvent::Burn(amount, _burn_from, note_opt) => { let mut burn_screen = BurnTokensScreen::new(identity_token_info, &self.app_context); burn_screen.group_action_id = Some(action_id); - burn_screen.amount_to_burn = amount.to_string(); + // Convert amount to Amount struct using the token configuration + burn_screen.amount = Some(Amount::from_token( + &burn_screen.identity_token_info, + *amount, + )); burn_screen.public_note = note_opt.clone(); *action |= AppAction::AddScreen(Screen::BurnTokensScreen(burn_screen)); } diff --git a/src/ui/identities/transfer_screen.rs b/src/ui/identities/transfer_screen.rs index 4240337b1..31d13e60f 100644 --- a/src/ui/identities/transfer_screen.rs +++ b/src/ui/identities/transfer_screen.rs @@ -115,9 +115,9 @@ impl TransferScreen { let amount_input = self.amount_input.get_or_insert_with(|| { AmountInput::new(Amount::new_dash(0.0)) - .label("Amount:") - .max_button(true) - .max_amount(Some(max_amount_credits)) + .with_label("Amount:") + .with_max_button(true) + .with_max_amount(Some(max_amount_credits)) }); // Check if input should be disabled when operation is in progress @@ -132,10 +132,7 @@ impl TransferScreen { let response = ui.add_enabled_ui(enabled, |ui| amount_input.show(ui)).inner; response.inner.update(&mut self.amount); - - if let Some(error) = &response.inner.error_message { - ui.colored_label(egui::Color32::DARK_RED, error); - } + // errors are handled inside AmountInput } fn render_to_identity_input(&mut self, ui: &mut Ui) { diff --git a/src/ui/identities/withdraw_screen.rs b/src/ui/identities/withdraw_screen.rs index 041daa70e..720b6c404 100644 --- a/src/ui/identities/withdraw_screen.rs +++ b/src/ui/identities/withdraw_screen.rs @@ -105,8 +105,8 @@ impl WithdrawalScreen { // Lazy initialization with basic configuration let amount_input = self.withdrawal_amount_input.get_or_insert_with(|| { AmountInput::new(Amount::new_dash(0.0)) - .label("Amount:") - .max_button(true) + .with_label("Amount:") + .with_max_button(true) }); // Check if input should be disabled when operation is in progress @@ -123,9 +123,7 @@ impl WithdrawalScreen { let response = ui.add_enabled_ui(enabled, |ui| amount_input.show(ui)).inner; response.inner.update(&mut self.withdrawal_amount); - if let Some(error) = &response.inner.error_message { - ui.colored_label(egui::Color32::DARK_RED, error); - } + // errors are handled inside AmountInput } fn render_address_input(&mut self, ui: &mut Ui) { diff --git a/src/ui/tokens/burn_tokens_screen.rs b/src/ui/tokens/burn_tokens_screen.rs index 96a3ac708..8292cccf9 100644 --- a/src/ui/tokens/burn_tokens_screen.rs +++ b/src/ui/tokens/burn_tokens_screen.rs @@ -1,9 +1,12 @@ +use crate::ui::components::amount_input::AmountInput; 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::contracts_documents::group_actions_screen::GroupActionsScreen; use crate::ui::helpers::{TransactionType, add_identity_key_chooser, render_group_action_text}; use crate::ui::theme::DashColors; +use crate::ui::tokens::tokens_screen::IdentityTokenIdentifier; use dash_sdk::dpp::data_contract::GroupContractPosition; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::data_contract::accessors::v1::DataContractV1Getters; @@ -25,6 +28,7 @@ use crate::app::{AppAction, BackendTasksExecutionMode}; use crate::backend_task::BackendTask; use crate::backend_task::tokens::TokenTask; use crate::context::AppContext; +use crate::model::amount::Amount; use crate::model::wallet::Wallet; use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; @@ -52,7 +56,9 @@ pub struct BurnTokensScreen { pub group_action_id: Option, // The user chooses how many tokens to burn - pub amount_to_burn: String, + pub amount: Option, + pub amount_input: Option, + pub max_amount: Option, // Maximum amount the user can burn based on their balance pub public_note: Option, status: BurnTokensStatus, @@ -72,6 +78,18 @@ pub struct BurnTokensScreen { impl BurnTokensScreen { pub fn new(identity_token_info: IdentityTokenInfo, app_context: &Arc) -> Self { + let token_balance = match app_context.identity_token_balances() { + Ok(identity_token_balances) => { + let itb = identity_token_balances; + let key = IdentityTokenIdentifier { + identity_id: identity_token_info.identity.identity.id(), + token_id: identity_token_info.token_id, + }; + itb.get(&key).map(|itb| itb.balance) + } + Err(_) => None, + }; + let possible_key = identity_token_info .identity .identity @@ -179,7 +197,9 @@ impl BurnTokensScreen { group, is_unilateral_group_member, group_action_id: None, - amount_to_burn: String::new(), + amount: None, + amount_input: None, + max_amount: token_balance, public_note: None, status: BurnTokensStatus::NotStarted, error_message, @@ -192,11 +212,23 @@ impl BurnTokensScreen { } /// Renders a text input for the user to specify an amount to burn - fn render_amount_input(&mut self, ui: &mut Ui) { - ui.horizontal(|ui| { - ui.label("Amount to Burn:"); - ui.text_edit_singleline(&mut self.amount_to_burn); + fn render_amount_input(&mut self, ui: &mut egui::Ui) { + let amount_input = self.amount_input.get_or_insert_with(|| { + let token_amount = Amount::from_token(&self.identity_token_info, 0); + let mut input = AmountInput::new(token_amount).with_label("Amount:"); + + if self.max_amount.is_some() { + input.set_show_max_button(self.max_amount.is_some()); + input.set_max_amount(self.max_amount); + } + + input }); + + let amount_response = amount_input.show(ui).inner; + // Update the amount based on user input + amount_response.update(&mut self.amount); + // errors are handled inside AmountInput } /// Renders a confirm popup with the final "Are you sure?" step @@ -207,19 +239,18 @@ impl BurnTokensScreen { .collapsible(false) .open(&mut is_open) .show(ui.ctx(), |ui| { - // Validate user input - let amount_ok = self.amount_to_burn.parse::().ok(); - if amount_ok.is_none() { - self.error_message = Some("Please enter a valid integer amount.".into()); - self.status = BurnTokensStatus::ErrorMessage("Invalid amount".into()); - self.show_confirmation_popup = false; - return; - } + 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.show_confirmation_popup = false; + return; + } + }; - ui.label(format!( - "Are you sure you want to burn {} tokens?", - self.amount_to_burn - )); + ui.label(format!("Are you sure you want to burn {}?", amount)); ui.add_space(10.0); @@ -265,7 +296,7 @@ impl BurnTokensScreen { } else { self.public_note.clone() }, - amount: amount_ok.unwrap(), + amount: amount.value(), group_info, })), BackendTask::TokenTask(Box::new(TokenTask::QueryMyTokenBalances)), @@ -504,7 +535,13 @@ impl ScreenLike for BurnTokensScreen { "You are signing an existing group Burn so you are not allowed to choose the amount.", ); ui.add_space(5.0); - ui.label(format!("Amount: {}", self.amount_to_burn)); + ui.label(format!( + "Amount: {}", + self.amount + .as_ref() + .map(|a| a.to_string()) + .unwrap_or_default() + )); } else { self.render_amount_input(ui); } diff --git a/src/ui/tokens/mint_tokens_screen.rs b/src/ui/tokens/mint_tokens_screen.rs index ab15559df..4a90e8aff 100644 --- a/src/ui/tokens/mint_tokens_screen.rs +++ b/src/ui/tokens/mint_tokens_screen.rs @@ -3,14 +3,17 @@ use crate::app::AppAction; use crate::backend_task::BackendTask; use crate::backend_task::tokens::TokenTask; use crate::context::AppContext; +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::identity_selector::IdentitySelector; 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::top_panel::add_top_panel; use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; +use crate::ui::components::{Component, ComponentResponse}; use crate::ui::contracts_documents::group_actions_screen::GroupActionsScreen; use crate::ui::helpers::{TransactionType, add_identity_key_chooser, render_group_action_text}; use crate::ui::identities::get_selected_wallet; @@ -58,7 +61,8 @@ pub struct MintTokensScreen { pub recipient_identity_id: String, - pub amount_to_mint: String, + pub amount: Option, + pub amount_input: Option, status: MintTokensStatus, error_message: Option, @@ -190,7 +194,8 @@ impl MintTokensScreen { group_action_id: None, known_identities, recipient_identity_id: "".to_string(), - amount_to_mint: "".to_string(), + amount: None, + amount_input: None, status: MintTokensStatus::NotStarted, error_message, app_context: app_context.clone(), @@ -201,15 +206,25 @@ impl MintTokensScreen { } } - /// Renders a text input for the user to specify an amount to mint + /// Renders an amount input for the user to specify an amount to mint fn render_amount_input(&mut self, ui: &mut Ui) { - ui.horizontal(|ui| { - ui.label("Amount to Mint:"); - ui.text_edit_singleline(&mut self.amount_to_mint); - - // Since it's minting, we often don't do "Max." - // But you could show a help text or put constraints if needed. + // Lazy initialization with proper token configuration + let amount_input = self.amount_input.get_or_insert_with(|| { + // Create appropriate Amount based on token configuration + let token_amount = Amount::from_token(&self.identity_token_info, 0); + AmountInput::new(token_amount).with_label("Amount to Mint:") }); + + // 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, + }; + + let response = ui.add_enabled_ui(enabled, |ui| amount_input.show(ui)).inner; + + response.inner.update(&mut self.amount); + // errors are handled inside AmountInput } /// Renders an optional text input for the user to specify a "Recipient Identity" @@ -237,13 +252,12 @@ impl MintTokensScreen { .open(&mut is_open) .show(ui.ctx(), |ui| { // Validate user input - let amount_ok = self.amount_to_mint.parse::().ok(); - if amount_ok.is_none() { + let Some(amount) = &self.amount else { self.error_message = Some("Please enter a valid amount.".into()); self.status = MintTokensStatus::ErrorMessage("Invalid amount".into()); self.show_confirmation_popup = false; return; - } + }; let maybe_identifier = if self.recipient_identity_id.trim().is_empty() { None @@ -266,7 +280,7 @@ impl MintTokensScreen { ui.label(format!( "Are you sure you want to mint {} token(s)?", - self.amount_to_mint + amount )); // If user provided a recipient: @@ -320,7 +334,7 @@ impl MintTokensScreen { } else { self.public_note.clone() }, - amount: amount_ok.unwrap(), + amount: amount.value(), recipient_id: maybe_identifier, group_info, }, @@ -557,7 +571,13 @@ impl ScreenLike for MintTokensScreen { "You are signing an existing group Mint so you are not allowed to choose the amount.", ); ui.add_space(5.0); - ui.label(format!("Amount: {}", self.amount_to_mint)); + ui.label(format!( + "Amount: {}", + self.amount + .as_ref() + .map(|a| a.to_string()) + .unwrap_or_default() + )); } else { self.render_amount_input(ui); } diff --git a/src/ui/tokens/tokens_screen/mod.rs b/src/ui/tokens/tokens_screen/mod.rs index d7a8cc691..2a7bbe6b2 100644 --- a/src/ui/tokens/tokens_screen/mod.rs +++ b/src/ui/tokens/tokens_screen/mod.rs @@ -17,7 +17,7 @@ use std::sync::{Arc, Mutex, RwLock}; use serde_json; use chrono::{DateTime, Duration, Utc}; -use dash_sdk::dpp::balances::credits::TokenAmount; +use dash_sdk::dpp::balances::credits::{TokenAmount}; use dash_sdk::dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; use dash_sdk::dpp::data_contract::associated_token::token_configuration::v0::{TokenConfigurationPresetFeatures, TokenConfigurationV0}; use dash_sdk::dpp::data_contract::associated_token::token_distribution_rules::v0::TokenDistributionRulesV0; @@ -56,13 +56,16 @@ use crate::backend_task::{BackendTask, NO_IDENTITIES_FOUND}; use crate::app::{AppAction, DesiredAppAction}; 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::amount_input::AmountInput; 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::top_panel::add_top_panel; use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; +use crate::ui::components::{Component, ComponentResponse}; use crate::ui::{BackendTaskSuccessResult, MessageType, RootScreenType, ScreenLike, ScreenType}; const EXP_FORMULA_PNG: &[u8] = include_bytes!("../../../../assets/exp_function.png"); @@ -71,6 +74,8 @@ const LOG_FORMULA_PNG: &[u8] = include_bytes!("../../../../assets/log_function.p const LINEAR_FORMULA_PNG: &[u8] = include_bytes!("../../../../assets/linear_function.png"); const POLYNOMIAL_FORMULA_PNG: &[u8] = include_bytes!("../../../../assets/polynomial_function.png"); +const DEFAULT_DECIMALS: u8 = 8; + pub fn load_formula_image(bytes: &[u8]) -> ColorImage { let image = ImageReader::new(std::io::Cursor::new(bytes)) .with_guessed_format() @@ -1001,8 +1006,10 @@ pub struct TokensScreen { token_description_input: String, should_capitalize_input: bool, decimals_input: String, - base_supply_input: String, - max_supply_input: String, + base_supply_amount: Option, + base_supply_input: Option, + max_supply_amount: Option, + max_supply_input: Option, start_as_paused_input: bool, main_control_group_input: String, show_token_creator_confirmation_popup: bool, @@ -1355,11 +1362,11 @@ impl TokensScreen { contract_keywords_input: String::new(), token_description_input: String::new(), should_capitalize_input: true, - decimals_input: 0.to_string(), - base_supply_input: TokenConfigurationV0::default_most_restrictive() - .base_supply() - .to_string(), - max_supply_input: String::new(), + decimals_input: DEFAULT_DECIMALS.to_string(), + base_supply_amount: None, + base_supply_input: None, + max_supply_amount: None, + max_supply_input: None, start_as_paused_input: false, show_advanced_keeps_history: false, token_advanced_keeps_history: TokenKeepsHistoryRulesV0::default_for_keeping_all_history( @@ -2104,9 +2111,11 @@ impl TokensScreen { )]; self.contract_keywords_input = "".to_string(); self.token_description_input = "".to_string(); - self.decimals_input = "8".to_string(); - self.base_supply_input = "100000".to_string(); - self.max_supply_input = "".to_string(); + self.decimals_input = DEFAULT_DECIMALS.to_string(); // + self.base_supply_input = None; + self.base_supply_amount = None; + self.max_supply_input = None; + self.max_supply_amount = None; self.start_as_paused_input = false; self.should_capitalize_input = true; self.token_advanced_keeps_history = @@ -2397,6 +2406,46 @@ impl TokensScreen { self.token_to_remove = None; } } + + /// Renders the base supply amount input using AmountInput component + fn render_base_supply_input(&mut self, ui: &mut egui::Ui) { + let decimals = self.decimals_input.parse::().unwrap_or(0); + let input = self + .base_supply_input + .get_or_insert_with(|| AmountInput::new(Amount::new(0, decimals))); + + if decimals != input.decimal_places() { + // Update decimals; it will change actual value but I guess this is what user expects + input.set_decimal_places(decimals); + } + + let response = input.show(ui); + response.inner.update(&mut self.base_supply_amount); + } + + /// Renders the max supply amount input using AmountInput component + fn render_max_supply_input(&mut self, ui: &mut egui::Ui) { + let decimals = self.decimals_input.parse::().unwrap_or(0); + + let input = self.max_supply_input.get_or_insert_with(|| { + let initial_amount = Amount::new( + TokenConfigurationV0::default_most_restrictive() + .max_supply() + .unwrap_or(0), + decimals, + ); + + AmountInput::new(initial_amount) + }); + + if decimals != input.decimal_places() { + // Update decimals; it will change actual value but I guess this is what user expects + input.set_decimal_places(decimals); + } + + let response = input.show(ui); + response.inner.update(&mut self.max_supply_amount); + } } // ───────────────────────────────────────────────────────────────── @@ -2935,9 +2984,11 @@ mod tests { TokenNameLanguage::English, true, )]; - token_creator_ui.base_supply_input = "5000000".to_string(); - token_creator_ui.max_supply_input = "10000000".to_string(); - token_creator_ui.decimals_input = "8".to_string(); + token_creator_ui.base_supply_input = None; + token_creator_ui.base_supply_amount = Some(Amount::new(5000000, 8)); + token_creator_ui.max_supply_input = None; + token_creator_ui.max_supply_amount = Some(Amount::new(10000000, 8)); + token_creator_ui.decimals_input = DEFAULT_DECIMALS.to_string(); token_creator_ui.start_as_paused_input = true; token_creator_ui.token_advanced_keeps_history = TokenKeepsHistoryRulesV0::default_for_keeping_all_history(true); diff --git a/src/ui/tokens/tokens_screen/my_tokens.rs b/src/ui/tokens/tokens_screen/my_tokens.rs index 6b6db3fd3..a7e3a02b2 100644 --- a/src/ui/tokens/tokens_screen/my_tokens.rs +++ b/src/ui/tokens/tokens_screen/my_tokens.rs @@ -523,10 +523,9 @@ impl TokensScreen { .token_configuration .conventions() .plural_form_by_language_code_or_default("en"); - let reward_amount = Amount::new( - explanation.total_amount, - decimal_places, - ).with_unit_name(unit_name); + let reward_amount = + Amount::new(explanation.total_amount, decimal_places) + .with_unit_name(unit_name); ui.label(format!("Total Estimated Rewards: {}", reward_amount)); ui.separator(); diff --git a/src/ui/tokens/tokens_screen/token_creator.rs b/src/ui/tokens/tokens_screen/token_creator.rs index c1f7f640f..e10ef0e25 100644 --- a/src/ui/tokens/tokens_screen/token_creator.rs +++ b/src/ui/tokens/tokens_screen/token_creator.rs @@ -11,7 +11,7 @@ use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::platform::Identifier; use eframe::epaint::Color32; -use egui::{ComboBox, Context, RichText, TextEdit, Ui}; +use egui::{ComboBox, Context, RichText, TextEdit, Ui}; use crate::app::{AppAction, BackendTasksExecutionMode}; use crate::backend_task::BackendTask; use crate::backend_task::tokens::TokenTask; @@ -245,13 +245,15 @@ impl TokensScreen { } // Row 2: Base Supply + // We put label manually to comply with grid layout; + // errors will be rendered in second column ui.label("Base Supply*:"); - ui.text_edit_singleline(&mut self.base_supply_input); + self.render_base_supply_input(ui); ui.end_row(); // Row 3: Max Supply ui.label("Max Supply:"); - ui.text_edit_singleline(&mut self.max_supply_input); + self.render_max_supply_input(ui); ui.end_row(); // Row 4: Contract Keywords @@ -807,19 +809,18 @@ impl TokensScreen { .parse::() .map_err(|_| "Invalid decimal places amount".to_string())?; let base_supply = self - .base_supply_input - .parse::() - .map_err(|_| "Invalid base supply amount".to_string())?; - let max_supply = if self.max_supply_input.is_empty() { - None - } else { - // If parse fails, error out - Some( - self.max_supply_input - .parse::() - .map_err(|_| "Invalid Max Supply".to_string())?, - ) - }; + .base_supply_amount + .as_ref() + .map(|amount| amount.value()) + .ok_or_else(|| "Please enter a valid base supply amount".to_string())?; + let max_supply = self + .max_supply_amount + .as_ref() + .map(|amount| { + let value = amount.value(); + if value > 0 { Some(value) } else { None } + }) + .unwrap_or(None); let start_paused = self.start_as_paused_input; let allow_transfers_to_frozen_identities = self.allow_transfers_to_frozen_identities; @@ -1017,14 +1018,20 @@ impl TokensScreen { ui.label( "Are you sure you want to register a new token contract with these settings?\n", ); - let max_supply_display = if self.max_supply_input.is_empty() { - "None".to_string() - } else { - self.max_supply_input.clone() - }; + let base_supply_display = self + .base_supply_amount + .as_ref() + .map(|amount| amount.to_string_opts(true, false)) + .unwrap_or_else(|| "0".to_string()); + let max_supply_display = self + .max_supply_amount + .as_ref() + .filter(|amount| amount.value() > 0) + .map(|amount| amount.to_string_opts(true, false)) + .unwrap_or_else(|| "None".to_string()); ui.label(format!( "Name: {}\nBase Supply: {}\nMax Supply: {}", - self.token_names_input[0].0, self.base_supply_input, max_supply_display, + self.token_names_input[0].0, base_supply_display, max_supply_display, )); ui.add_space(10.0); diff --git a/src/ui/tokens/transfer_tokens_screen.rs b/src/ui/tokens/transfer_tokens_screen.rs index e35d5b736..3426ce36e 100644 --- a/src/ui/tokens/transfer_tokens_screen.rs +++ b/src/ui/tokens/transfer_tokens_screen.rs @@ -120,8 +120,8 @@ impl TransferTokensScreen { .as_ref() .unwrap_or(&Amount::from(&self.identity_token_balance)), ) - .label("Amount:") - .max_button(true), + .with_label("Amount:") + .with_max_button(true), ); self.amount_input @@ -142,13 +142,7 @@ impl TransferTokensScreen { let response = ui.add_enabled_ui(enabled, |ui| amount_input.show(ui)).inner; response.inner.update(&mut self.amount); - - if let Some(error) = &response.inner.error_message { - ui.colored_label( - DashColors::error_color(ui.ctx().style().visuals.dark_mode), - error, - ); - } + // errors are handled inside AmountInput } fn render_to_identity_input(&mut self, ui: &mut Ui) {