Add choose recipient step

This commit is contained in:
edouard 2022-10-27 18:22:13 +02:00
parent e1209d2cff
commit 5b9414260b
11 changed files with 479 additions and 72 deletions

View File

@ -5,4 +5,5 @@ pub enum Menu {
Spend,
Settings,
Coins,
CreateSpendTx,
}

View File

@ -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())
}

View File

@ -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<ReceivePanel> for Box<dyn State> {
}
}
/// redirect to another state with a message menu
pub fn redirect(menu: Menu) -> Command<Message> {
Command::perform(async { menu }, |menu| {
Message::View(view::Message::Menu(menu))
})
}
#[cfg(test)]
mod tests {
use super::*;

View File

@ -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<SpendTx>,
warning: Option<Error>,
}
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<dyn Daemon + Sync + Send>,
_cache: &Cache,
_message: Message,
) -> Command<Message> {
Command::none()
}
fn load(&self, daemon: Arc<dyn Daemon + Sync + Send>) -> Command<Message> {
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<SpendPanel> for Box<dyn State> {
fn from(s: SpendPanel) -> Box<dyn State> {
Box::new(s)
}
}

View File

@ -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<usize>,
spend_txs: Vec<SpendTx>,
warning: Option<Error>,
}
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<dyn Daemon + Sync + Send>,
_cache: &Cache,
message: Message,
) -> Command<Message> {
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<dyn Daemon + Sync + Send>) -> Command<Message> {
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<SpendPanel> for Box<dyn State> {
fn from(s: SpendPanel) -> Box<dyn State> {
Box::new(s)
}
}
pub struct CreateSpendPanel {
coins: Vec<Coin>,
draft: step::TransactionDraft,
current: usize,
steps: Vec<Box<dyn step::Step>>,
}
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<dyn Daemon + Sync + Send>,
cache: &Cache,
message: Message,
) -> Command<Message> {
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<dyn Daemon + Sync + Send>) -> Command<Message> {
let daemon = daemon.clone();
Command::perform(
async move {
daemon
.list_coins()
.map(|res| res.coins)
.map_err(|e| e.into())
},
Message::Coins,
)
}
}
impl From<CreateSpendPanel> for Box<dyn State> {
fn from(s: CreateSpendPanel) -> Box<dyn State> {
Box::new(s)
}
}

View File

@ -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<OutPoint>,
outputs: HashMap<Address, Amount>,
feerate: u64,
generated: Option<Psbt>,
}
pub trait Step {
fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message>;
fn update(
&mut self,
daemon: Arc<dyn Daemon + Sync + Send>,
cache: &Cache,
message: Message,
) -> Command<Message>;
fn apply(&self, draft: &mut TransactionDraft);
}
pub struct ChooseRecipients {
recipients: Vec<Recipient>,
}
impl std::default::Default for ChooseRecipients {
fn default() -> Self {
Self {
recipients: vec![Recipient::default()],
}
}
}
impl Step for ChooseRecipients {
fn update(
&mut self,
_daemon: Arc<dyn Daemon + Sync + Send>,
_cache: &Cache,
message: Message,
) -> Command<Message> {
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<Address, Amount> = 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<String>,
amount: form::Value<String>,
}
impl Recipient {
fn amount(&self) -> Result<u64, Error> {
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::CreateSpendMessage> {
view::spend::step::recipient_view(i, &self.address, &self.amount)
}
}

View File

@ -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)]

View File

@ -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<Element<'a, Message>>>(
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()
}
}
}

View File

@ -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()

View File

@ -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<Element<'a, Message>>,
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<String>,
amount: &form::Value<String>,
) -> 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()
}

View File

@ -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<T> {
@ -75,7 +75,7 @@ impl<'a, Message: 'a + Clone> From<Form<'a, Message>> 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),
)