diff --git a/src/ui/components/amount_input.rs b/src/ui/components/amount_input.rs index 0230eb91f..abc071145 100644 --- a/src/ui/components/amount_input.rs +++ b/src/ui/components/amount_input.rs @@ -1,5 +1,6 @@ use crate::model::amount::Amount; use crate::ui::components::{Component, ComponentResponse}; +use dash_sdk::dpp::balances::credits::MAX_CREDITS; use dash_sdk::dpp::fee::Credits; use egui::{InnerResponse, Response, TextEdit, Ui, Vec2, WidgetText}; @@ -113,7 +114,7 @@ impl AmountInput { unit_name: amount.unit_name().map(|s| s.to_string()), label: None, hint_text: None, - max_amount: None, + max_amount: Some(MAX_CREDITS), min_amount: Some(1), // Default minimum is 1 (greater than zero) show_max_button: false, desired_width: None, @@ -176,6 +177,17 @@ impl AmountInput { self } + /// Sets value of the input field. + /// + /// This will update the internal state and mark the component as changed. + pub fn set_value(&mut self, value: Amount) -> &mut Self { + self.amount_str = value.to_string_without_unit(); + self.decimal_places = value.decimal_places(); + self.unit_name = value.unit_name().map(|s| s.to_string()); + self.changed = true; // Mark as changed to trigger validation + self + } + /// Sets the hint text for the input field. pub fn with_hint_text>(mut self, hint_text: T) -> Self { self.hint_text = Some(hint_text.into()); @@ -197,6 +209,8 @@ impl AmountInput { /// Sets the maximum amount allowed (mutable reference version). /// Use this for dynamic configuration when the max amount changes at runtime (e.g., balance updates). + /// + /// Defaults to [`MAX_CREDITS`](dash_sdk::dpp::balances::credits::MAX_CREDITS). pub fn set_max_amount(&mut self, max_amount: Option) -> &mut Self { self.max_amount = max_amount; self diff --git a/src/ui/contracts_documents/group_actions_screen.rs b/src/ui/contracts_documents/group_actions_screen.rs index 5e40ce43c..3c1f3e7c5 100644 --- a/src/ui/contracts_documents/group_actions_screen.rs +++ b/src/ui/contracts_documents/group_actions_screen.rs @@ -429,7 +429,9 @@ impl GroupActionsScreen { } TokenEvent::ChangePriceForDirectPurchase(schedule, note_opt) => { let mut change_price_screen = - SetTokenPriceScreen::new(identity_token_info, &self.app_context); + SetTokenPriceScreen::new(identity_token_info, &self.app_context) + .with_schedule(schedule.clone()); + change_price_screen.group_action_id = Some(action_id); change_price_screen.token_pricing_schedule = format!("{:?}", schedule); change_price_screen.public_note = note_opt.clone(); diff --git a/src/ui/tokens/direct_token_purchase_screen.rs b/src/ui/tokens/direct_token_purchase_screen.rs index a06f23b94..4e2de7d98 100644 --- a/src/ui/tokens/direct_token_purchase_screen.rs +++ b/src/ui/tokens/direct_token_purchase_screen.rs @@ -3,6 +3,8 @@ 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; +use dash_sdk::dpp::data_contract::associated_token::token_configuration_convention::accessors::v0::TokenConfigurationConventionV0Getters; use dash_sdk::dpp::fee::Credits; use dash_sdk::dpp::tokens::token_pricing_schedule::TokenPricingSchedule; use eframe::egui::{self, Color32, Context, Ui}; @@ -13,14 +15,16 @@ 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, DASH_DECIMAL_PLACES}; use crate::model::wallet::Wallet; -use crate::ui::components::Component; +use crate::ui::components::amount_input::AmountInput; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::tokens_subscreen_chooser_panel::add_tokens_subscreen_chooser_panel; use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; +use crate::ui::components::{Component, ComponentResponse}; use crate::ui::helpers::{TransactionType, add_identity_key_chooser}; use crate::ui::identities::get_selected_wallet; use crate::ui::identities::keys::add_key_screen::AddKeyScreen; @@ -47,11 +51,11 @@ pub struct PurchaseTokenScreen { pub identity_token_info: IdentityTokenInfo, selected_key: Option, - // Specific to this transition - amount_to_purchase: String, - total_agreed_price: String, + // Specific to this transition - using AmountInput components following design pattern + amount_to_purchase_input: Option, + amount_to_purchase_value: Option, fetched_pricing_schedule: Option, - calculated_price: Option, + calculated_price_credits: Option, pricing_fetch_attempted: bool, /// Screen stuff @@ -91,10 +95,10 @@ impl PurchaseTokenScreen { Self { identity_token_info, selected_key: possible_key, - amount_to_purchase: "".to_string(), - total_agreed_price: "".to_string(), + amount_to_purchase_input: None, + amount_to_purchase_value: None, fetched_pricing_schedule: None, - calculated_price: None, + calculated_price_credits: None, pricing_fetch_attempted: false, status: PurchaseTokensStatus::NotStarted, error_message: None, @@ -106,17 +110,35 @@ impl PurchaseTokenScreen { } } - /// Renders a text input for the user to specify an amount to purchase + /// Renders AmountInput components for the user to specify an amount to purchase fn render_amount_input(&mut self, ui: &mut Ui) -> AppAction { let mut action = AppAction::None; ui.horizontal(|ui| { - ui.label("Amount to Purchase:"); - let response = ui.text_edit_singleline(&mut self.amount_to_purchase); + // Use AmountInput for token amount with lazy initialization + let amount_input = self.amount_to_purchase_input.get_or_insert_with(|| { + AmountInput::new( + Amount::new( + 0, + self.identity_token_info + .token_config + .conventions() + .decimals(), + ) + .with_unit_name(&self.identity_token_info.token_alias), + ) + .with_label("Amount to Purchase:") + .with_hint_text("Enter token amount to purchase") + .with_min_amount(Some(1)) + }); - // When amount changes, recalculate the price if we have pricing schedule - if response.changed() { + let response = amount_input.show(ui); + response.inner.update(&mut self.amount_to_purchase_value); + + // When amount changes, update domain data and recalculate the price + if response.inner.has_changed() { self.recalculate_price(); + self.confirmation_dialog = None; } // Fetch pricing button @@ -144,14 +166,46 @@ impl PurchaseTokenScreen { if let Some(pricing_schedule) = &self.fetched_pricing_schedule { ui.add_space(5.0); ui.label("Current pricing:"); + let dark_mode = ui.ctx().style().visuals.dark_mode; + match pricing_schedule { - TokenPricingSchedule::SinglePrice(price) => { - ui.label(format!(" Fixed price: {} credits per token", price)); + TokenPricingSchedule::SinglePrice(price_per_unit) => { + // Convert price per smallest unit to price per whole token for display, guarding for the minimal + // representable value (using Amount ref display which pads decimals properly) + if *price_per_unit == 0 { + ui.colored_label( + DashColors::error_color(dark_mode), + " Fixed price: FREE (pricing schedule stores 0 credits per unit)", + ); + } else { + let price_per_token = (*price_per_unit as u128) + .saturating_mul(self.token_decimal_multiplier() as u128) + .min(u64::MAX as u128) + as u64; + let price = Amount::new(price_per_token, DASH_DECIMAL_PLACES) + .with_unit_name("DASH"); + ui.label(format!(" Fixed price: {} per token", price)); + } } TokenPricingSchedule::SetPrices(tiers) => { ui.label(" Tiered pricing:"); - for (amount, price) in tiers { - ui.label(format!(" {} tokens: {} credits each", amount, price)); + for (amount_value, price_per_unit) in tiers { + let amount = Amount::from_token(&self.identity_token_info, *amount_value); + // Convert price per smallest unit to price per token for display + if *price_per_unit == 0 { + ui.colored_label( + DashColors::error_color(dark_mode), + format!(" {} tokens: FREE (tier stores 0 credits)", amount), + ); + } else { + let price_per_token = (*price_per_unit as u128) + .saturating_mul(self.token_decimal_multiplier() as u128) + .min(u64::MAX as u128) + as u64; + let price = Amount::new(price_per_token, DASH_DECIMAL_PLACES) + .with_unit_name("DASH"); + ui.label(format!(" {} tokens: {} each", amount, price)); + } } } } @@ -162,11 +216,12 @@ impl PurchaseTokenScreen { /// Recalculates the total price based on amount and pricing schedule fn recalculate_price(&mut self) { - if let (Some(pricing_schedule), Ok(amount)) = ( + if let (Some(pricing_schedule), Some(amount_value)) = ( &self.fetched_pricing_schedule, - self.amount_to_purchase.parse::(), + &self.amount_to_purchase_value, ) { - let price_per_token = match pricing_schedule { + let amount = amount_value.value(); + let price_per_unit = match pricing_schedule { TokenPricingSchedule::SinglePrice(price) => *price, TokenPricingSchedule::SetPrices(tiers) => { // Find the appropriate tier for this amount @@ -180,42 +235,46 @@ impl PurchaseTokenScreen { } }; - let total_price = amount.saturating_mul(price_per_token); - self.calculated_price = Some(total_price); - self.total_agreed_price = total_price.to_string(); + // The price from Platform is per smallest unit, and amount is in smallest units + // So we multiply them directly using wider arithmetic to avoid overflow + let total_price = (amount as u128) + .saturating_mul(price_per_unit as u128) + .min(u64::MAX as u128) as u64; + self.calculated_price_credits = Some(total_price); } else { - self.calculated_price = None; + self.calculated_price_credits = None; } } + fn token_decimal_multiplier(&self) -> u64 { + 10u64.pow( + self.identity_token_info + .token_config + .conventions() + .decimals() as u32, + ) + } + /// Renders a confirm popup with the final "Are you sure?" step fn show_confirmation_popup(&mut self, ui: &mut Ui) -> AppAction { - // Validate user input - let amount_ok = self.amount_to_purchase.parse::().ok(); - if amount_ok.is_none() { + 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.confirmation_dialog = None; return AppAction::None; - } + }; - let total_agreed_price_ok: Option = self.total_agreed_price.parse::().ok(); - if total_agreed_price_ok.is_none() { - self.error_message = Some("Please enter a valid total agreed price.".into()); - self.status = PurchaseTokensStatus::ErrorMessage("Invalid total agreed price".into()); + 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.confirmation_dialog = None; return AppAction::None; - } + }; - let dialog = self.confirmation_dialog.get_or_insert_with(|| { - ConfirmationDialog::new( - "Confirm Purchase".to_string(), - format!( - "Are you sure you want to purchase {} token(s) for {} Credits?", - self.amount_to_purchase, self.total_agreed_price - ), - ) - }); + let Some(dialog) = self.confirmation_dialog.as_mut() else { + return AppAction::None; + }; match dialog.show(ui).inner.dialog_response { Some(ConfirmationStatus::Confirmed) => { @@ -226,7 +285,6 @@ impl PurchaseTokenScreen { .as_secs(); self.status = PurchaseTokensStatus::WaitingForResult(now); - // Dispatch the actual backend purchase action AppAction::BackendTasks( vec![ BackendTask::TokenTask(Box::new(TokenTask::PurchaseTokens { @@ -236,9 +294,8 @@ impl PurchaseTokenScreen { ), token_position: self.identity_token_info.token_position, signing_key: self.selected_key.clone().expect("Expected a key"), - amount: amount_ok.expect("Expected a valid amount"), - total_agreed_price: total_agreed_price_ok - .expect("Expected a valid total agreed price"), + amount: amount.value(), + total_agreed_price: total_price_credits, })), BackendTask::TokenTask(Box::new(TokenTask::QueryMyTokenBalances)), ], @@ -454,12 +511,17 @@ impl ScreenLike for PurchaseTokenScreen { ui.add_space(10.0); - // Display calculated price - if let Some(calculated_price) = self.calculated_price { + // Display calculated price and total agreed price input + if let Some(calculated_price_credits) = self.calculated_price_credits { ui.group(|ui| { ui.heading("Calculated total price:"); - ui.label(format!("{} credits", calculated_price)); + let dash_amount = Amount::new(calculated_price_credits, DASH_DECIMAL_PLACES) + .with_unit_name("DASH"); + ui.label(format!("{} DASH ({} credits)",dash_amount, calculated_price_credits)); ui.label("Note: This is the calculated price based on the current pricing schedule."); + + ui.add_space(10.0); + }); } else if self.fetched_pricing_schedule.is_some() { ui.colored_label( @@ -474,9 +536,15 @@ impl ScreenLike for PurchaseTokenScreen { ui.separator(); ui.add_space(10.0); - // Purchase button (disabled if no pricing is available) - let can_purchase = - self.fetched_pricing_schedule.is_some() && self.calculated_price.is_some(); + // Purchase button (disabled if no valid amounts are available) + let can_purchase = self.fetched_pricing_schedule.is_some() + && self.calculated_price_credits.unwrap_or_default() > 0 + && self + .amount_to_purchase_value + .as_ref() + .map(|v| v.value()) + .unwrap_or_default() + > 0; let purchase_text = "Purchase".to_string(); if can_purchase { @@ -486,14 +554,29 @@ impl ScreenLike for PurchaseTokenScreen { .corner_radius(3.0); if ui.add(button).clicked() && self.confirmation_dialog.is_none() { - // Validation will be done in show_confirmation_popup - self.confirmation_dialog = Some(ConfirmationDialog::new( - "Confirm Purchase".to_string(), - format!( - "Are you sure you want to purchase {} token(s) for {} Credits?", - self.amount_to_purchase, self.total_agreed_price - ), - )); + if let (Some(amount), Some(total_price_credits)) = ( + self.amount_to_purchase_value.as_ref(), + self.calculated_price_credits, + ) { + let total_price_dash = + Amount::new(total_price_credits, DASH_DECIMAL_PLACES) + .with_unit_name("DASH"); + + self.confirmation_dialog = Some(ConfirmationDialog::new( + "Confirm Purchase".to_string(), + format!( + "Are you sure you want to purchase {} for {} ({} Credits)?", + amount, total_price_dash, total_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()); + } } } else { let button = egui::Button::new( @@ -576,3 +659,106 @@ impl ScreenWithWalletUnlock for PurchaseTokenScreen { self.error_message.as_ref() } } + +#[cfg(test)] +mod tests { + use crate::model::amount::DASH_DECIMAL_PLACES; + + #[test] + fn test_token_pricing_storage_and_calculation() { + // Test how prices should be stored and calculated + + // Case 1: Token with 8 decimals (like the user's case) + let token_decimals_8 = 8u8; + let user_price_per_token_dash = 0.001; // User wants 0.001 DASH per token + let user_price_per_token_credits = + (user_price_per_token_dash * 10f64.powi(DASH_DECIMAL_PLACES as i32)) as u64; + + println!("Test 1 - Token with 8 decimals, price 0.001 DASH per token:"); + println!( + " User enters: {} DASH per token", + user_price_per_token_dash + ); + println!( + " In credits: {} credits per token", + user_price_per_token_credits + ); + + // Platform expects price per smallest unit, not per token + let decimal_divisor_8 = 10u64.pow(token_decimals_8 as u32); + let platform_price_per_smallest_unit = user_price_per_token_credits / decimal_divisor_8; + + println!( + " Platform stores: {} credits per smallest unit", + platform_price_per_smallest_unit + ); + + // When buying 1 token (100,000,000 smallest units) + let tokens_to_buy = 1u64; + let amount_smallest_units = tokens_to_buy * 10u64.pow(token_decimals_8 as u32); + let total_price = amount_smallest_units * platform_price_per_smallest_unit; + + println!( + " Buying {} token ({} smallest units)", + tokens_to_buy, amount_smallest_units + ); + println!( + " Total: {} credits (should be {} credits for 0.001 DASH)", + total_price, user_price_per_token_credits + ); + + assert_eq!( + total_price, user_price_per_token_credits, + "Total should match expected price" + ); + + // Case 2: Token with 2 decimals + let token_decimals_2 = 2u8; + let user_price_2 = 0.1; // 0.1 DASH per token + let user_price_credits_2 = (user_price_2 * 10f64.powi(DASH_DECIMAL_PLACES as i32)) as u64; + + let divisor_2 = 10u64.pow(token_decimals_2 as u32); + let platform_price_2 = user_price_credits_2 / divisor_2; + + // Buy 5 tokens + let amount_2 = 5 * 10u64.pow(token_decimals_2 as u32); // 500 smallest units + let total_2 = amount_2 * platform_price_2; + + println!("\nTest 2 - Token with 2 decimals, 5 tokens at 0.1 DASH each:"); + println!( + " Platform price: {} credits per smallest unit", + platform_price_2 + ); + println!(" Total for 5 tokens: {} credits", total_2); + + assert_eq!( + total_2, + 5 * user_price_credits_2, + "Should be 0.5 DASH total" + ); + + // Case 3: Token with 0 decimals + let _token_decimals_0 = 0u8; + let user_price_0 = 0.05; // 0.05 DASH per token + let user_price_credits_0 = (user_price_0 * 10f64.powi(DASH_DECIMAL_PLACES as i32)) as u64; + + // With 0 decimals, price per token = price per smallest unit + let platform_price_0 = user_price_credits_0; // No division needed + + let amount_0 = 10; // 10 tokens = 10 smallest units (no decimals) + let total_0 = amount_0 * platform_price_0; + + println!("\nTest 3 - Token with 0 decimals, 10 tokens at 0.05 DASH each:"); + println!( + " Platform price: {} credits per smallest unit", + platform_price_0 + ); + println!(" Total for 10 tokens: {} credits", total_0); + + assert_eq!( + total_0, + 10 * user_price_credits_0, + "Should be 0.5 DASH total" + ); + } +} diff --git a/src/ui/tokens/set_token_price_screen.rs b/src/ui/tokens/set_token_price_screen.rs index 977ff1b09..36d699e94 100644 --- a/src/ui/tokens/set_token_price_screen.rs +++ b/src/ui/tokens/set_token_price_screen.rs @@ -3,7 +3,10 @@ use crate::app::AppAction; use crate::backend_task::BackendTask; use crate::backend_task::tokens::TokenTask; use crate::context::AppContext; +use crate::model::amount::{Amount, DASH_DECIMAL_PLACES}; use crate::model::wallet::Wallet; +use crate::ui::components::ComponentResponse; +use crate::ui::components::amount_input::AmountInput; use crate::ui::components::component_trait::Component; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::left_panel::add_left_panel; @@ -22,6 +25,7 @@ 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; 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; use dash_sdk::dpp::data_contract::associated_token::token_distribution_rules::accessors::v0::TokenDistributionRulesV0Getters; use dash_sdk::dpp::data_contract::change_control_rules::authorized_action_takers::AuthorizedActionTakers; use dash_sdk::dpp::data_contract::group::Group; @@ -46,6 +50,24 @@ pub enum PricingType { RemovePricing, } +impl From for PricingType { + fn from(schedule: TokenPricingSchedule) -> Self { + match schedule { + TokenPricingSchedule::SinglePrice(_) => PricingType::SinglePrice, + TokenPricingSchedule::SetPrices(_) => PricingType::TieredPricing, + } + } +} + +impl From> for PricingType { + fn from(schedule: Option) -> Self { + match schedule { + Some(schedule) => PricingType::from(schedule), + None => PricingType::RemovePricing, + } + } +} + /// Internal states for the mint process. #[derive(PartialEq)] pub enum SetTokenPriceStatus { @@ -65,9 +87,15 @@ pub struct SetTokenPriceScreen { pub group_action_id: Option, pub token_pricing_schedule: String, - pricing_type: PricingType, - single_price: String, - tiered_prices: Vec<(String, String)>, + /// Token pricing schedule to use; if None, we will remove the pricing schedule + pub 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) status: SetTokenPriceStatus, error_message: Option, @@ -84,14 +112,50 @@ pub struct SetTokenPriceScreen { show_password: bool, } +/// 1 Dash = 100,000,000,000 credits +pub const CREDITS_PER_DASH: Credits = 100_000_000_000; + impl SetTokenPriceScreen { - /// Converts Dash amount to credits (1 Dash = 100,000,000,000 credits) - fn dash_to_credits(dash_amount: f64) -> Credits { - (dash_amount * 100_000_000_000.0) as Credits + fn token_decimal_divisor(&self) -> u64 { + 10u64.pow( + self.identity_token_info + .token_config + .conventions() + .decimals() as u32, + ) + } + + fn minimum_price_amount(&self) -> Amount { + Amount::new(self.token_decimal_divisor(), DASH_DECIMAL_PLACES).with_unit_name("DASH") + } + + fn validate_price_for_token(&self, price: &Amount) -> Result { + let credits_price_per_token = price.value(); + if credits_price_per_token == 0 { + return Err("Price must be greater than 0".to_string()); + } + + let decimal_divisor = self.token_decimal_divisor(); + + if credits_price_per_token < decimal_divisor { + return Err(format!( + "Price too low for this token's precision. Minimum price is {}.", + self.minimum_price_amount() + )); + } + + if credits_price_per_token % decimal_divisor != 0 { + return Err(format!( + "Price must be in multiples of {} to match the token decimals.", + self.minimum_price_amount() + )); + } + + Ok(credits_price_per_token / decimal_divisor) } pub fn new(identity_token_info: IdentityTokenInfo, app_context: &Arc) -> Self { - let possible_key = identity_token_info + let possible_key: Option<&IdentityPublicKey> = identity_token_info .identity .identity .get_first_public_key_matching( @@ -202,9 +266,10 @@ impl SetTokenPriceScreen { is_unilateral_group_member, group_action_id: None, token_pricing_schedule: "".to_string(), - pricing_type: PricingType::SinglePrice, - single_price: "".to_string(), - tiered_prices: vec![("1".to_string(), "".to_string())], + pricing_type: PricingType::RemovePricing, + single_price_amount: None, + single_price_input: None, + tiered_prices: vec![(None, None)], status: SetTokenPriceStatus::NotStarted, error_message: None, app_context: app_context.clone(), @@ -216,6 +281,57 @@ impl SetTokenPriceScreen { } } + pub fn with_schedule(self, token_pricing_schedule: Option) -> Self { + let token_decimals = self + .identity_token_info + .token_config + .conventions() + .decimals(); + let decimal_multiplier = 10u64.pow(token_decimals as u32); + + let (single_price_amount, tiered_prices) = match &token_pricing_schedule { + Some(TokenPricingSchedule::SinglePrice(price_per_smallest_unit)) => { + // Convert price per smallest unit back to price per token for display + let price_per_token = price_per_smallest_unit * decimal_multiplier; + let amount = + Amount::new(price_per_token, DASH_DECIMAL_PLACES).with_unit_name("DASH"); + (Some(amount), vec![(None, None)]) + } + Some(TokenPricingSchedule::SetPrices(prices)) => { + let tiered_prices = prices + .iter() + .map(|(amount, price_per_smallest_unit)| { + // Create amount input for token threshold + let amount_input = AmountInput::new(Amount::from_token( + &self.identity_token_info, + *amount, + )) + .with_hint_text("Token amount threshold"); + + // Convert price per smallest unit back to price per token for display + let price_per_token = price_per_smallest_unit * decimal_multiplier; + let price = Amount::new(price_per_token, DASH_DECIMAL_PLACES) + .with_unit_name("DASH"); + let price_input = AmountInput::new(price) + .with_hint_text("Enter price in Dash") + .with_min_amount(Some(1)); + (Some(amount_input), Some(price_input)) + }) + .collect::>(); + + (None, tiered_prices) + } + None => (None, vec![(None, None)]), + }; + + Self { + pricing_type: PricingType::from(token_pricing_schedule), + single_price_amount, + tiered_prices, + ..self + } + } + /// Renders the pricing input UI fn render_pricing_input(&mut self, ui: &mut Ui) { // Radio buttons for pricing type @@ -242,30 +358,47 @@ impl SetTokenPriceScreen { match self.pricing_type { PricingType::SinglePrice => { ui.label("Set a fixed price per token:"); - ui.horizontal(|ui| { - ui.label("Price per token (Dash):"); - ui.text_edit_singleline(&mut self.single_price); + + if self.token_decimal_divisor() > 1 { + ui.colored_label( + Color32::DARK_RED, + format!( + "Prices must be multiples of {} to match this token's precision.", + self.minimum_price_amount() + ), + ); + } + + // Lazy initialization of AmountInput following the design pattern + let single_price_input = self.single_price_input.get_or_insert_with(|| { + let initial_amount = self + .single_price_amount + .as_ref() + .cloned() + .unwrap_or_else(|| Amount::new_dash(0.0)); + AmountInput::new(initial_amount) + .with_label("Price per token:") + .with_hint_text("Enter price in Dash") + .with_min_amount(Some(1)) // Minimum 1 credit (very small amount) }); - // Show preview - if !self.single_price.is_empty() { - if let Ok(price) = self.single_price.parse::() { - if price > 0.0 { - ui.add_space(5.0); - let credits = Self::dash_to_credits(price); - ui.colored_label( - Color32::DARK_GREEN, - format!("Price: {} Dash per token ({} credits)", price, credits), - ); - } else { - ui.colored_label(Color32::DARK_RED, "X Price must be greater than 0"); - } - } else { - ui.colored_label( - Color32::DARK_RED, - "X Invalid price - must be a positive number", - ); - } + let response = single_price_input.show(ui); + + // Update the domain data if there's a valid change + if response.inner.has_changed() && response.inner.is_valid() { + self.single_price_amount = response.inner.changed_value().clone(); + } + + // Show validation preview + if let Some(amount) = &self.single_price_amount + && amount.value() > 0 + { + ui.add_space(5.0); + let credits = amount.value(); + ui.colored_label( + Color32::DARK_GREEN, + format!("Price: {} per token ({} credits)", amount, credits), + ); } } PricingType::TieredPricing => { @@ -313,50 +446,44 @@ impl SetTokenPriceScreen { }); }) .body(|mut body| { - for (i, (amount, price)) in self.tiered_prices.iter_mut().enumerate() { - body.row(25.0, |mut row| { + for i in 0..self.tiered_prices.len() { + body.row(30.0, |mut row| { row.col(|ui| { if i == 0 { - // First tier is hardcoded to 1 token - ui.label("1"); - *amount = "1".to_string(); // Ensure it's always 1 + // First tier is hardcoded to 1 token - create AmountInput with value 1 + let amount_input = + self.tiered_prices[i].0.get_or_insert_with(|| { + AmountInput::new(Amount::from_token( + &self.identity_token_info, + 1, + )) + .with_hint_text("Token amount threshold") + }); + amount_input.show(ui); + // Make sure it's always 1 - we could disable editing or show as read-only } else { - let dark_mode = ui.ctx().style().visuals.dark_mode; - ui.add( - egui::TextEdit::singleline(amount) - .hint_text( - RichText::new("100").color(Color32::GRAY), - ) - .desired_width(100.0) - .text_color( - crate::ui::theme::DashColors::text_primary( - dark_mode, - ), - ) - .background_color( - crate::ui::theme::DashColors::input_background( - dark_mode, - ), - ), - ); + // Other tiers use AmountInput for token amounts + let amount_input = + self.tiered_prices[i].0.get_or_insert_with(|| { + AmountInput::new(Amount::from_token( + &self.identity_token_info, + 0, + )) + .with_hint_text("Token amount threshold") + }); + amount_input.show(ui); } }); row.col(|ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; - ui.add( - egui::TextEdit::singleline(price) - .hint_text(RichText::new("50").color(Color32::GRAY)) - .desired_width(120.0) - .text_color(crate::ui::theme::DashColors::text_primary( - dark_mode, - )) - .background_color( - crate::ui::theme::DashColors::input_background( - dark_mode, - ), - ), - ); - ui.label(" Dash"); + // Use AmountInput for price with lazy initialization + let price_input = + self.tiered_prices[i].1.get_or_insert_with(|| { + AmountInput::new(Amount::new_dash(0.0)) + .with_hint_text("Enter price in Dash") + .with_min_amount(Some(1)) // Minimum 1 credit + }); + + let _response = price_input.show(ui); }); row.col(|ui| { if can_remove && i > 0 && ui.small_button("X").clicked() { @@ -374,8 +501,8 @@ impl SetTokenPriceScreen { ui.add_space(10.0); ui.horizontal(|ui| { if ui.button("+ Add Tier").clicked() { - // Add empty tier - user will fill in values - self.tiered_prices.push(("".to_string(), "".to_string())); + // Add empty tier with lazy initialization + self.tiered_prices.push((None, None)); } }); @@ -394,19 +521,20 @@ impl SetTokenPriceScreen { let mut valid_tiers = Vec::new(); let mut has_errors = false; - for (amount_str, price_str) in &self.tiered_prices { - if amount_str.trim().is_empty() || price_str.trim().is_empty() { - continue; - } + for (amount_input, price_input) in &self.tiered_prices { + let Some(price) = price_input.as_ref().and_then(|input| input.current_value()) else { + continue; // Skip if no price input is available + }; - match (amount_str.parse::(), price_str.parse::()) { - (Ok(amount), Ok(price)) if price > 0.0 => { - valid_tiers.push((amount, price)); - } - _ => { - has_errors = true; - } - } + let Some(amount_value) = amount_input + .as_ref() + .and_then(|input| input.current_value()) + else { + has_errors = true; + continue; // Skip if amount is invalid + }; + + valid_tiers.push((amount_value, price)); } // Only show preview if there are valid tiers or errors @@ -414,7 +542,7 @@ impl SetTokenPriceScreen { ui.group(|ui| { // Sort tiers by amount if !valid_tiers.is_empty() { - valid_tiers.sort_by_key(|(amount, _)| *amount); + valid_tiers.sort_by_key(|(amount, _)| amount.value()); } if has_errors { @@ -424,12 +552,20 @@ impl SetTokenPriceScreen { if !valid_tiers.is_empty() { ui.colored_label(Color32::DARK_GREEN, "Pricing Structure:"); for (amount, price) in &valid_tiers { - let credits = Self::dash_to_credits(*price); + let credits = price.value(); ui.label(format!( - " - {} or more tokens: {} Dash each ({} credits)", + " - {} or more tokens: {} each ({} credits)", amount, price, credits )); } + + if self.token_decimal_divisor() > 1 { + ui.add_space(5.0); + ui.label(format!( + "Each tier price must be a multiple of {}.", + self.minimum_price_amount() + )); + } } }); } @@ -439,49 +575,36 @@ impl SetTokenPriceScreen { fn create_pricing_schedule(&self) -> Result, String> { match self.pricing_type { PricingType::RemovePricing => Ok(None), - PricingType::SinglePrice => { - if self.single_price.trim().is_empty() { - return Err("Please enter a price".to_string()); - } - match self.single_price.trim().parse::() { - Ok(dash_price) if dash_price > 0.0 => { - let credits_price = Self::dash_to_credits(dash_price); - Ok(Some(TokenPricingSchedule::SinglePrice(credits_price))) - } - Ok(_) => Err("Price must be greater than 0".to_string()), - Err(_) => Err("Invalid price - must be a positive number".to_string()), - } - } + PricingType::SinglePrice => match &self.single_price_amount { + Some(amount) => self + .validate_price_for_token(amount) + .map(|price| Some(TokenPricingSchedule::SinglePrice(price))), + None => Err("Please enter a price".to_string()), + }, PricingType::TieredPricing => { let mut map = std::collections::BTreeMap::new(); - for (amount_str, price_str) in &self.tiered_prices { - if amount_str.trim().is_empty() || price_str.trim().is_empty() { + for (amount_input, price_input) in &self.tiered_prices { + let Some(price) = price_input.as_ref().and_then(|input| input.current_value()) + else { continue; - } + }; - let amount = amount_str.trim().parse::().map_err(|_| { - format!( - "Invalid amount '{}' - must be a positive number", - amount_str.trim() - ) - })?; - let dash_price = price_str.trim().parse::().map_err(|_| { - format!( - "Invalid price '{}' - must be a positive number", - price_str.trim() - ) - })?; - - if dash_price <= 0.0 { - return Err(format!( - "Price '{}' must be greater than 0", - price_str.trim() - )); + let Some(amount_value) = amount_input + .as_ref() + .and_then(|input| input.current_value()) + else { + continue; + }; + + let amount = amount_value.value(); + if amount == 0 { + continue; } - let credits_price = Self::dash_to_credits(dash_price); - map.insert(amount, credits_price); + let price_per_smallest_unit = self.validate_price_for_token(&price)?; + + map.insert(amount, price_per_smallest_unit); } if map.is_empty() { @@ -497,45 +620,31 @@ impl SetTokenPriceScreen { 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::SinglePrice => match &self.single_price_amount { + Some(amount) => self.validate_price_for_token(amount).map(|_| ()), + None => Err("Please enter a price".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() { + for (amount_input, price_input) in &self.tiered_prices { + let Some(price) = price_input.as_ref().and_then(|input| input.current_value()) + else { continue; - } + }; - let _amount = amount_str.trim().parse::().map_err(|_| { - format!( - "Invalid amount '{}' - must be a whole number", - amount_str.trim() - ) - })?; + let Some(amount_value) = amount_input + .as_ref() + .and_then(|input| input.current_value()) + else { + continue; + }; - 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() - )); + if amount_value.value() == 0 { + continue; } + self.validate_price_for_token(&price)?; valid_tiers += 1; } @@ -559,10 +668,10 @@ impl SetTokenPriceScreen { "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::() { + if let Some(amount) = &self.single_price_amount { format!( - "Are you sure you want to set a fixed price of {} Dash per token?", - dash_price + "Are you sure you want to set a fixed price of {} per token?", + amount ) } else { "Are you sure you want to set the pricing schedule?".to_string() @@ -570,20 +679,22 @@ impl SetTokenPriceScreen { } 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() { + for (amount_input, price_input) in &self.tiered_prices { + let Some(price) = price_input.as_ref().and_then(|input| input.current_value()) else { + continue; // Skip if no price input is available + }; + + let Some(amount_value) = amount_input + .as_ref() + .and_then(|input| input.current_value()) + else { continue; - } - 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.push_str(&format!( + "\n - {} or more tokens: {} each", + amount_value, price + )); } message } diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index dee816ee8..99094982e 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -611,30 +611,31 @@ impl WalletsBalancesScreen { .corner_radius(4.0); if ui.add(remove_button).clicked() - && let Some(selected_wallet) = &self.selected_wallet { - let wallet = selected_wallet.read().unwrap(); - let alias = wallet - .alias - .clone() - .unwrap_or_else(|| "Unnamed Wallet".to_string()); - let seed_hash = wallet.seed_hash(); - drop(wallet); - - self.pending_wallet_removal = Some(seed_hash); - self.pending_wallet_removal_alias = Some(alias.clone()); - - let message = format!( - "Removing wallet \"{}\" will delete its local data, including addresses, balances, and asset locks stored on this device. Identities linked to it will remain but the keys derived from this wallet will no longer work unless the wallet is re-imported. Continue?", - alias - ); + && let Some(selected_wallet) = &self.selected_wallet + { + let wallet = selected_wallet.read().unwrap(); + let alias = wallet + .alias + .clone() + .unwrap_or_else(|| "Unnamed Wallet".to_string()); + let seed_hash = wallet.seed_hash(); + drop(wallet); + + self.pending_wallet_removal = Some(seed_hash); + self.pending_wallet_removal_alias = Some(alias.clone()); + + let message = format!( + "Removing wallet \"{}\" will delete its local data, including addresses, balances, and asset locks stored on this device. Identities linked to it will remain but the keys derived from this wallet will no longer work unless the wallet is re-imported. Continue?", + alias + ); - self.remove_wallet_dialog = Some( - ConfirmationDialog::new("Remove Wallet", message) - .confirm_text(Some("Remove")) - .cancel_text(Some("Cancel")) - .danger_mode(true), - ); - } + self.remove_wallet_dialog = Some( + ConfirmationDialog::new("Remove Wallet", message) + .confirm_text(Some("Remove")) + .cancel_text(Some("Cancel")) + .danger_mode(true), + ); + } if let Some(dialog) = self.remove_wallet_dialog.as_mut() { let response = dialog.show(ui);