Add choose recipient step
This commit is contained in:
parent
e1209d2cff
commit
5b9414260b
@ -5,4 +5,5 @@ pub enum Menu {
|
||||
Spend,
|
||||
Settings,
|
||||
Coins,
|
||||
CreateSpendTx,
|
||||
}
|
||||
|
||||
@ -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())
|
||||
}
|
||||
|
||||
@ -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::*;
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
141
gui/src/app/state/spend/mod.rs
Normal file
141
gui/src/app/state/spend/mod.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
164
gui/src/app/state/spend/step.rs
Normal file
164
gui/src/app/state/spend/step.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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)]
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
85
gui/src/app/view/spend/step.rs
Normal file
85
gui/src/app/view/spend/step.rs
Normal 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()
|
||||
}
|
||||
@ -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),
|
||||
)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user