From 5b9414260b34c37e9cddc7d79eb0a16d8d42767e Mon Sep 17 00:00:00 2001 From: edouard Date: Thu, 27 Oct 2022 18:22:13 +0200 Subject: [PATCH] Add choose recipient step --- gui/src/app/menu.rs | 1 + gui/src/app/mod.rs | 3 +- gui/src/app/state/mod.rs | 9 +- gui/src/app/state/spend.rs | 65 -------- gui/src/app/state/spend/mod.rs | 141 +++++++++++++++++ gui/src/app/state/spend/step.rs | 164 ++++++++++++++++++++ gui/src/app/view/message.rs | 13 ++ gui/src/app/view/mod.rs | 50 +++++- gui/src/app/view/{spend.rs => spend/mod.rs} | 16 +- gui/src/app/view/spend/step.rs | 85 ++++++++++ gui/src/ui/component/form.rs | 4 +- 11 files changed, 479 insertions(+), 72 deletions(-) delete mode 100644 gui/src/app/state/spend.rs create mode 100644 gui/src/app/state/spend/mod.rs create mode 100644 gui/src/app/state/spend/step.rs rename gui/src/app/view/{spend.rs => spend/mod.rs} (78%) create mode 100644 gui/src/app/view/spend/step.rs diff --git a/gui/src/app/menu.rs b/gui/src/app/menu.rs index 1fca2c30..65baad12 100644 --- a/gui/src/app/menu.rs +++ b/gui/src/app/menu.rs @@ -5,4 +5,5 @@ pub enum Menu { Spend, Settings, Coins, + CreateSpendTx, } diff --git a/gui/src/app/mod.rs b/gui/src/app/mod.rs index 85663144..0c50c2b9 100644 --- a/gui/src/app/mod.rs +++ b/gui/src/app/mod.rs @@ -21,7 +21,7 @@ pub use minisafe::config::Config as DaemonConfig; pub use config::Config; pub use message::Message; -use state::{CoinsPanel, Home, ReceivePanel, SpendPanel, State}; +use state::{CoinsPanel, CreateSpendPanel, Home, ReceivePanel, SpendPanel, State}; use crate::{ app::{cache::Cache, error::Error, menu::Menu}, @@ -66,6 +66,7 @@ impl App { menu::Menu::Coins => CoinsPanel::new(&self.cache.coins).into(), menu::Menu::Receive => ReceivePanel::default().into(), menu::Menu::Spend => SpendPanel::new(&self.cache.coins, &self.cache.spend_txs).into(), + menu::Menu::CreateSpendTx => CreateSpendPanel::new(&self.cache.coins).into(), }; self.state.load(self.daemon.clone()) } diff --git a/gui/src/app/state/mod.rs b/gui/src/app/state/mod.rs index 270b0f6d..f6f12043 100644 --- a/gui/src/app/state/mod.rs +++ b/gui/src/app/state/mod.rs @@ -13,7 +13,7 @@ use crate::daemon::{model::Coin, Daemon}; pub use coins::CoinsPanel; pub use settings::SettingsState; -pub use spend::SpendPanel; +pub use spend::{CreateSpendPanel, SpendPanel}; pub trait State { fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message>; @@ -141,6 +141,13 @@ impl From for Box { } } +/// redirect to another state with a message menu +pub fn redirect(menu: Menu) -> Command { + Command::perform(async { menu }, |menu| { + Message::View(view::Message::Menu(menu)) + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/gui/src/app/state/spend.rs b/gui/src/app/state/spend.rs deleted file mode 100644 index 1dc5ac92..00000000 --- a/gui/src/app/state/spend.rs +++ /dev/null @@ -1,65 +0,0 @@ -use std::sync::Arc; - -use iced::{pure::Element, Command}; - -use super::State; -use crate::{ - app::{cache::Cache, error::Error, menu::Menu, message::Message, view}, - daemon::{ - model::{Coin, SpendTx}, - Daemon, - }, -}; - -pub struct SpendPanel { - spend_txs: Vec, - warning: Option, -} - -impl SpendPanel { - pub fn new(_coins: &[Coin], spend_txs: &[SpendTx]) -> Self { - Self { - spend_txs: spend_txs.to_vec(), - warning: None, - } - } -} - -impl State for SpendPanel { - fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> { - view::dashboard( - &Menu::Spend, - cache, - self.warning.as_ref(), - view::spend::spend_view(&self.spend_txs), - ) - } - - fn update( - &mut self, - _daemon: Arc, - _cache: &Cache, - _message: Message, - ) -> Command { - Command::none() - } - - fn load(&self, daemon: Arc) -> Command { - let daemon = daemon.clone(); - Command::perform( - async move { - daemon - .list_spend_txs() - .map(|res| res.spend_txs) - .map_err(|e| e.into()) - }, - Message::SpendTxs, - ) - } -} - -impl From for Box { - fn from(s: SpendPanel) -> Box { - Box::new(s) - } -} diff --git a/gui/src/app/state/spend/mod.rs b/gui/src/app/state/spend/mod.rs new file mode 100644 index 00000000..dabc1aa6 --- /dev/null +++ b/gui/src/app/state/spend/mod.rs @@ -0,0 +1,141 @@ +mod step; +use std::sync::Arc; + +use iced::{pure::Element, Command}; + +use super::{redirect, State}; +use crate::{ + app::{cache::Cache, error::Error, menu::Menu, message::Message, view}, + daemon::{ + model::{Coin, SpendTx}, + Daemon, + }, +}; + +pub struct SpendPanel { + selected_tx: Option, + spend_txs: Vec, + warning: Option, +} + +impl SpendPanel { + pub fn new(_coins: &[Coin], spend_txs: &[SpendTx]) -> Self { + Self { + spend_txs: spend_txs.to_vec(), + warning: None, + selected_tx: None, + } + } +} + +impl State for SpendPanel { + fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> { + view::dashboard( + &Menu::Spend, + cache, + self.warning.as_ref(), + view::spend::spend_view(&self.spend_txs), + ) + } + + fn update( + &mut self, + _daemon: Arc, + _cache: &Cache, + message: Message, + ) -> Command { + match message { + Message::SpendTxs(res) => match res { + Err(e) => self.warning = Some(e), + Ok(txs) => { + self.warning = None; + self.spend_txs = txs; + } + }, + Message::View(view::Message::Select(i)) => { + self.selected_tx = Some(i); + } + _ => {} + } + Command::none() + } + + fn load(&self, daemon: Arc) -> Command { + let daemon = daemon.clone(); + Command::perform( + async move { + daemon + .list_spend_txs() + .map(|res| res.spend_txs) + .map_err(|e| e.into()) + }, + Message::SpendTxs, + ) + } +} + +impl From for Box { + fn from(s: SpendPanel) -> Box { + Box::new(s) + } +} + +pub struct CreateSpendPanel { + coins: Vec, + draft: step::TransactionDraft, + current: usize, + steps: Vec>, +} + +impl CreateSpendPanel { + pub fn new(coins: &[Coin]) -> Self { + Self { + coins: coins.to_vec(), + draft: step::TransactionDraft::default(), + current: 0, + steps: vec![Box::new(step::ChooseRecipients::default())], + } + } +} + +impl State for CreateSpendPanel { + fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> { + self.steps.get(self.current).unwrap().view(cache) + } + + fn update( + &mut self, + daemon: Arc, + cache: &Cache, + message: Message, + ) -> Command { + if matches!(message, Message::View(view::Message::Close)) { + return redirect(Menu::Spend); + } + + if let Some(step) = self.steps.get_mut(self.current) { + return step.update(daemon, cache, message); + } + + Command::none() + } + + fn load(&self, daemon: Arc) -> Command { + let daemon = daemon.clone(); + Command::perform( + async move { + daemon + .list_coins() + .map(|res| res.coins) + .map_err(|e| e.into()) + }, + Message::Coins, + ) + } +} + +impl From for Box { + fn from(s: CreateSpendPanel) -> Box { + Box::new(s) + } +} diff --git a/gui/src/app/state/spend/step.rs b/gui/src/app/state/spend/step.rs new file mode 100644 index 00000000..be703e93 --- /dev/null +++ b/gui/src/app/state/spend/step.rs @@ -0,0 +1,164 @@ +use std::collections::HashMap; +use std::str::FromStr; +use std::sync::Arc; + +use iced::pure::{column, Element}; +use iced::Command; +use minisafe::miniscript::bitcoin::{util::psbt::Psbt, Address, Amount, Denomination, OutPoint}; + +use crate::{ + app::{cache::Cache, error::Error, menu::Menu, message::Message, view}, + daemon::{model::Coin, Daemon}, + ui::component::form, +}; + +#[derive(Default)] +pub struct TransactionDraft { + inputs: Vec, + outputs: HashMap, + feerate: u64, + generated: Option, +} + +pub trait Step { + fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message>; + fn update( + &mut self, + daemon: Arc, + cache: &Cache, + message: Message, + ) -> Command; + + fn apply(&self, draft: &mut TransactionDraft); +} + +pub struct ChooseRecipients { + recipients: Vec, +} + +impl std::default::Default for ChooseRecipients { + fn default() -> Self { + Self { + recipients: vec![Recipient::default()], + } + } +} + +impl Step for ChooseRecipients { + fn update( + &mut self, + _daemon: Arc, + _cache: &Cache, + message: Message, + ) -> Command { + match message { + Message::View(view::Message::CreateSpend(msg)) => match &msg { + view::CreateSpendMessage::AddRecipient => { + self.recipients.push(Recipient::default()); + } + view::CreateSpendMessage::DeleteRecipient(i) => { + self.recipients.remove(*i); + } + view::CreateSpendMessage::RecipientEdited(i, _, _) => { + self.recipients.get_mut(*i).unwrap().update(msg); + } + _ => {} + }, + _ => {} + } + Command::none() + } + + fn apply(&self, draft: &mut TransactionDraft) { + let mut outputs: HashMap = HashMap::new(); + for recipient in &self.recipients { + outputs.insert( + Address::from_str(&recipient.address.value).expect("Checked before"), + Amount::from_sat(recipient.amount().expect("Checked before")), + ); + } + draft.outputs = outputs; + } + + fn view<'a>(&'a self, _cache: &'a Cache) -> Element<'a, view::Message> { + view::spend::step::choose_recipients_view( + self.recipients + .iter() + .enumerate() + .map(|(i, recipient)| recipient.view(i).map(view::Message::CreateSpend)) + .collect(), + !self.recipients.iter().any(|recipient| !recipient.valid()), + ) + } +} + +#[derive(Default)] +struct Recipient { + address: form::Value, + amount: form::Value, +} + +impl Recipient { + fn amount(&self) -> Result { + if self.amount.value.is_empty() { + return Err(Error::Unexpected("Amount should be non-zero".to_string())); + } + + let amount = Amount::from_str_in(&self.amount.value, Denomination::Bitcoin) + .map_err(|_| Error::Unexpected("cannot parse output amount".to_string()))?; + + if amount.to_sat() == 0 { + return Err(Error::Unexpected("Amount should be non-zero".to_string())); + } + + if let Ok(address) = Address::from_str(&self.address.value) { + if amount <= address.script_pubkey().dust_value() { + return Err(Error::Unexpected( + "Amount must be superior to script dust value".to_string(), + )); + } + } + + Ok(amount.to_sat()) + } + + fn valid(&self) -> bool { + !self.address.value.is_empty() + && self.address.valid + && !self.amount.value.is_empty() + && self.amount.valid + } + + fn update(&mut self, message: view::CreateSpendMessage) { + match message { + view::CreateSpendMessage::RecipientEdited(_, "address", address) => { + self.address.value = address; + if self.address.value.is_empty() { + // Make the error disappear if we deleted the invalid address + self.address.valid = true; + } else if Address::from_str(&self.address.value).is_ok() { + self.address.valid = true; + if !self.amount.value.is_empty() { + self.amount.valid = self.amount().is_ok(); + } + } else { + self.address.valid = false; + } + } + view::CreateSpendMessage::RecipientEdited(_, "amount", amount) => { + self.amount.value = amount; + if !self.amount.value.is_empty() { + self.amount.valid = self.amount().is_ok(); + } else { + // Make the error disappear if we deleted the invalid amount + self.amount.valid = true; + } + } + _ => {} + }; + } + + fn view(&self, i: usize) -> Element { + view::spend::step::recipient_view(i, &self.address, &self.amount) + } +} diff --git a/gui/src/app/view/message.rs b/gui/src/app/view/message.rs index ba779777..80f0bd41 100644 --- a/gui/src/app/view/message.rs +++ b/gui/src/app/view/message.rs @@ -8,6 +8,19 @@ pub enum Message { Close, Select(usize), Settings(usize, SettingsMessage), + CreateSpend(CreateSpendMessage), + Next, +} + +#[derive(Debug, Clone)] +pub enum CreateSpendMessage { + AddRecipient, + DeleteRecipient(usize), + SelectInput(usize), + RecipientEdited(usize, &'static str, String), + FeerateEdited(String), + Generate, + Save, } #[derive(Debug, Clone)] diff --git a/gui/src/app/view/mod.rs b/gui/src/app/view/mod.rs index 701d7146..ee4cac6b 100644 --- a/gui/src/app/view/mod.rs +++ b/gui/src/app/view/mod.rs @@ -18,7 +18,7 @@ use iced::{ use crate::ui::{ color, component::{badge, button, separation, text::*}, - icon::{coin_icon, home_icon, receive_icon, send_icon, settings_icon}, + icon::{coin_icon, cross_icon, home_icon, receive_icon, send_icon, settings_icon}, util::Collection, }; @@ -260,3 +260,51 @@ impl widget::container::StyleSheet for MainSectionStyle { } } } + +pub fn modal<'a, T: Into>>( + is_previous: bool, + warning: Option<&Error>, + content: T, +) -> Element<'a, Message> { + column() + .push(warn(warning)) + .push( + container( + row() + .push(if is_previous { + column() + .push(button::transparent(None, "< Previous")) + .width(Length::Fill) + } else { + column().width(Length::Fill) + }) + .align_items(iced::Alignment::Center) + .push(button::primary(Some(cross_icon()), "Close").on_press(Message::Close)), + ) + .padding(10) + .style(ModalSectionStyle), + ) + .push(modal_section(container(scrollable(content)))) + .width(Length::Fill) + .height(Length::Fill) + .into() +} + +fn modal_section<'a, T: 'a>(menu: widget::Container<'a, T>) -> widget::Container<'a, T> { + container(menu.max_width(1500)) + .padding(20) + .style(ModalSectionStyle) + .center_x() + .width(Length::Fill) + .height(Length::Fill) +} + +pub struct ModalSectionStyle; +impl widget::container::StyleSheet for ModalSectionStyle { + fn style(&self) -> widget::container::Style { + widget::container::Style { + background: color::BACKGROUND.into(), + ..widget::container::Style::default() + } + } +} diff --git a/gui/src/app/view/spend.rs b/gui/src/app/view/spend/mod.rs similarity index 78% rename from gui/src/app/view/spend.rs rename to gui/src/app/view/spend/mod.rs index c69ef48c..9dc38819 100644 --- a/gui/src/app/view/spend.rs +++ b/gui/src/app/view/spend/mod.rs @@ -1,17 +1,29 @@ +pub mod step; + use iced::{ pure::{button, column, container, row, Element}, Alignment, Length, }; use crate::{ + app::menu::Menu, daemon::model::SpendTx, - ui::component::{badge, button::Style, card, text::*}, + ui::{ + component::{badge, button, card, text::*}, + icon, + }, }; use super::message::Message; pub fn spend_view<'a>(spend_txs: &[SpendTx]) -> Element<'a, Message> { column() + .push( + row().push(column().width(Length::Fill)).push( + button::primary(Some(icon::plus_icon()), "Create a new transaction") + .on_press(Message::Menu(Menu::CreateSpendTx)), + ), + ) .push( container( row() @@ -52,7 +64,7 @@ fn spend_tx_list_view<'a>(i: usize, _tx: &SpendTx) -> Element<'a, Message> { ) .padding(10) .on_press(Message::Select(i)) - .style(Style::TransparentBorder), + .style(button::Style::TransparentBorder), ) .style(card::SimpleCardStyle) .into() diff --git a/gui/src/app/view/spend/step.rs b/gui/src/app/view/spend/step.rs new file mode 100644 index 00000000..815450fe --- /dev/null +++ b/gui/src/app/view/spend/step.rs @@ -0,0 +1,85 @@ +use iced::{ + pure::{column, container, row, widget, Element}, + Alignment, Length, +}; + +use crate::{ + app::view::{message::*, modal}, + ui::{ + component::{ + button, form, + text::{text, Text}, + }, + icon, + util::Collection, + }, +}; + +pub fn choose_recipients_view<'a>( + recipients: Vec>, + is_valid: bool, +) -> Element<'a, Message> { + modal( + false, + None, + column() + .push(text("Choose recipients").bold().size(50)) + .push( + column() + .push(widget::Column::with_children(recipients).spacing(10)) + .push( + button::transparent(Some(icon::plus_icon()), "Add recipient") + .on_press(Message::CreateSpend(CreateSpendMessage::AddRecipient)), + ) + .max_width(1000) + .spacing(10), + ) + .push_maybe(if is_valid { + Some( + button::primary(None, "Next") + .on_press(Message::Next) + .width(Length::Units(100)), + ) + } else { + None + }) + .spacing(20) + .align_items(Alignment::Center), + ) +} + +pub fn recipient_view<'a>( + index: usize, + address: &form::Value, + amount: &form::Value, +) -> Element<'a, CreateSpendMessage> { + row() + .push( + form::Form::new("Address", address, move |msg| { + CreateSpendMessage::RecipientEdited(index, "address", msg) + }) + .warning("Please enter correct bitcoin address") + .size(20) + .padding(10), + ) + .push( + container( + form::Form::new("Amount", amount, move |msg| { + CreateSpendMessage::RecipientEdited(index, "amount", msg) + }) + .warning("Please enter correct amount") + .size(20) + .padding(10), + ) + .width(Length::Units(250)), + ) + .spacing(5) + .push( + button::transparent(Some(icon::trash_icon()), "") + .on_press(CreateSpendMessage::DeleteRecipient(index)) + .width(Length::Shrink), + ) + .align_items(Alignment::Center) + .width(Length::Fill) + .into() +} diff --git a/gui/src/ui/component/form.rs b/gui/src/ui/component/form.rs index 34933e55..861993d8 100644 --- a/gui/src/ui/component/form.rs +++ b/gui/src/ui/component/form.rs @@ -5,7 +5,7 @@ use iced::pure::{ }; use iced::Length; -use crate::ui::{color, component::text::text}; +use crate::ui::{color, component::text::*}; #[derive(Debug, Clone)] pub struct Value { @@ -75,7 +75,7 @@ impl<'a, Message: 'a + Clone> From> for Element<'a, Message> { return container( column() .push(form.input.style(InvalidFormStyle)) - .push(text(message).color(color::ALERT)) + .push(text(message).color(color::ALERT).small()) .width(Length::Fill) .spacing(5), )