From 6b2c9a61e035e5c02b55807a2a275bb5d1cb9cfc Mon Sep 17 00:00:00 2001 From: edouard Date: Mon, 24 Apr 2023 15:27:43 +0200 Subject: [PATCH 1/4] gui: merge spend creation steps --- gui/src/app/state/spend/mod.rs | 5 +- gui/src/app/state/spend/step.rs | 436 +++++++++++++++----------------- gui/src/app/view/spend/step.rs | 347 +++++++++++++------------ gui/ui/src/theme.rs | 30 ++- 4 files changed, 401 insertions(+), 417 deletions(-) diff --git a/gui/src/app/state/spend/mod.rs b/gui/src/app/state/spend/mod.rs index cffb9865..fda7f111 100644 --- a/gui/src/app/state/spend/mod.rs +++ b/gui/src/app/state/spend/mod.rs @@ -26,8 +26,7 @@ impl CreateSpendPanel { draft: step::TransactionDraft::default(), current: 0, steps: vec![ - Box::new(step::ChooseRecipients::new(coins)), - Box::new(step::ChooseCoins::new( + Box::new(step::DefineSpend::new( descriptor, coins.to_vec(), timelock, @@ -72,7 +71,7 @@ impl State for CreateSpendPanel { } if let Some(step) = self.steps.get_mut(self.current) { - return step.update(daemon, cache, &self.draft, message); + return step.update(daemon, cache, message); } Command::none() diff --git a/gui/src/app/state/spend/step.rs b/gui/src/app/state/spend/step.rs index 33cd9434..a85a792d 100644 --- a/gui/src/app/state/spend/step.rs +++ b/gui/src/app/state/spend/step.rs @@ -28,7 +28,6 @@ const DUST_OUTPUT_SATS: u64 = 5_000; #[derive(Default, Clone)] pub struct TransactionDraft { inputs: Vec, - outputs: HashMap, generated: Option, } @@ -38,36 +37,77 @@ pub trait Step { &mut self, daemon: Arc, cache: &Cache, - draft: &TransactionDraft, message: Message, ) -> Command; fn apply(&self, _draft: &mut TransactionDraft) {} fn load(&mut self, _draft: &TransactionDraft) {} } -pub struct ChooseRecipients { +pub struct DefineSpend { balance_available: Amount, recipients: Vec, is_valid: bool, is_duplicate: bool, + + descriptor: LianaDescriptor, + timelock: u16, + coins: Vec<(Coin, bool)>, + amount_left_to_select: Option, + feerate: form::Value, + generated: Option, + warning: Option, } -impl ChooseRecipients { - pub fn new(coins: &[Coin]) -> Self { +impl DefineSpend { + pub fn new( + descriptor: LianaDescriptor, + coins: Vec, + timelock: u16, + blockheight: u32, + ) -> Self { + let balance_available = coins + .iter() + .filter_map(|coin| { + if coin.spend_info.is_none() { + Some(coin.amount) + } else { + None + } + }) + .sum(); + let mut coins: Vec<(Coin, bool)> = coins + .into_iter() + .filter_map(|c| { + if c.spend_info.is_none() { + Some((c, false)) + } else { + None + } + }) + .collect(); + coins.sort_by(|(a, _), (b, _)| { + if remaining_sequence(a, blockheight, timelock) + == remaining_sequence(b, blockheight, timelock) + { + // bigger amount first + b.amount.cmp(&a.amount) + } else { + // smallest blockheight (remaining_sequence) first + a.block_height.cmp(&b.block_height) + } + }); Self { - balance_available: coins - .iter() - .filter_map(|coin| { - if coin.spend_info.is_none() { - Some(coin.amount) - } else { - None - } - }) - .sum(), + balance_available, + descriptor, + timelock, + generated: None, + coins, recipients: vec![Recipient::default()], is_valid: false, is_duplicate: false, + feerate: form::Value::default(), + amount_left_to_select: None, + warning: None, } } fn check_valid(&mut self) { @@ -84,51 +124,170 @@ impl ChooseRecipients { } } } + fn amount_left_to_select(&mut self) { + // We need the feerate in order to compute the required amount of BTC to + // select. Return early if we don't to not do unnecessary computation. + let feerate = match self.feerate.value.parse::() { + Ok(f) => f, + Err(_) => { + self.amount_left_to_select = None; + return; + } + }; + + // The coins to be included in this transaction. + let selected_coins: Vec<_> = self + .coins + .iter() + .filter_map(|(c, selected)| if *selected { Some(c) } else { None }) + .collect(); + + // A dummy representation of the transaction that will be computed, for + // the purpose of computing its size in order to anticipate the fees needed. + // NOTE: we make the conservative estimation a change output will always be + // needed. + let tx_template = bitcoin::Transaction { + version: 2, + lock_time: bitcoin::PackedLockTime(0), + input: selected_coins + .iter() + .map(|_| bitcoin::TxIn::default()) + .collect(), + output: self + .recipients + .iter() + .filter_map(|recipient| { + if recipient.valid() { + Some(bitcoin::TxOut { + script_pubkey: Address::from_str(&recipient.address.value) + .unwrap() + .script_pubkey(), + value: recipient.amount().unwrap(), + }) + } else { + None + } + }) + .collect(), + }; + // nValue size + scriptPubKey CompactSize + OP_0 + PUSH32 + + const CHANGE_TXO_SIZE: usize = 8 + 1 + 1 + 1 + 32; + let satisfaction_vsize = self.descriptor.max_sat_weight() / 4; + let transaction_size = + tx_template.vsize() + satisfaction_vsize * tx_template.input.len() + CHANGE_TXO_SIZE; + + // Now the calculation of the amount left to be selected by the user is a simple + // substraction between the value needed by the transaction to be created and the + // value that was selected already. + let selected_amount = selected_coins.iter().map(|c| c.amount.to_sat()).sum(); + let output_sum: u64 = tx_template.output.iter().map(|o| o.value).sum(); + let needed_amount: u64 = transaction_size as u64 * feerate + output_sum; + self.amount_left_to_select = Some(Amount::from_sat( + needed_amount.saturating_sub(selected_amount), + )); + } } -impl Step for ChooseRecipients { +impl Step for DefineSpend { fn update( &mut self, - _daemon: Arc, + daemon: Arc, cache: &Cache, - _draft: &TransactionDraft, message: Message, ) -> Command { if let Message::View(view::Message::CreateSpend(msg)) = message { - match &msg { + match msg { view::CreateSpendMessage::AddRecipient => { self.recipients.push(Recipient::default()); } view::CreateSpendMessage::DeleteRecipient(i) => { - self.recipients.remove(*i); + self.recipients.remove(i); } view::CreateSpendMessage::RecipientEdited(i, _, _) => { self.recipients - .get_mut(*i) + .get_mut(i) .unwrap() .update(cache.network, msg); } + + view::CreateSpendMessage::FeerateEdited(s) => { + if s.parse::().is_ok() { + self.feerate.value = s; + self.feerate.valid = true; + self.amount_left_to_select(); + } else if s.is_empty() { + self.feerate.value = "".to_string(); + self.feerate.valid = true; + self.amount_left_to_select = None; + } else { + self.feerate.valid = false; + self.amount_left_to_select = None; + } + self.warning = None; + } + view::CreateSpendMessage::Generate => { + let inputs: Vec = self + .coins + .iter() + .filter_map( + |(coin, selected)| if *selected { Some(coin.outpoint) } else { None }, + ) + .collect(); + let mut outputs: HashMap = HashMap::new(); + for recipient in &self.recipients { + outputs.insert( + Address::from_str(&recipient.address.value).expect("Checked before"), + recipient.amount().expect("Checked before"), + ); + } + let feerate_vb = self.feerate.value.parse::().unwrap_or(0); + self.warning = None; + return Command::perform( + async move { + daemon + .create_spend_tx(&inputs, &outputs, feerate_vb) + .map(|res| res.psbt) + .map_err(|e| e.into()) + }, + Message::Psbt, + ); + } + view::CreateSpendMessage::SelectCoin(i) => { + if let Some(coin) = self.coins.get_mut(i) { + coin.1 = !coin.1; + self.amount_left_to_select(); + } + } _ => {} } - self.check_valid(); + Command::none() + } else { + if let Message::Psbt(res) = message { + match res { + Ok(psbt) => { + self.generated = Some(psbt); + return Command::perform(async {}, |_| Message::View(view::Message::Next)); + } + Err(e) => self.warning = Some(e), + } + } + Command::none() } - 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"), - recipient.amount().expect("Checked before"), - ); - } - draft.outputs = outputs; + draft.inputs = self + .coins + .iter() + .filter_map(|(coin, selected)| if *selected { Some(*coin) } else { None }) + .collect(); + draft.generated = self.generated.clone(); } - fn view<'a>(&'a self, _cache: &'a Cache) -> Element<'a, view::Message> { - view::spend::step::choose_recipients_view( + fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> { + view::spend::step::create_spend_tx( + cache, &self.balance_available, self.recipients .iter() @@ -143,6 +302,11 @@ impl Step for ChooseRecipients { ), self.is_valid, self.is_duplicate, + self.timelock, + &self.coins, + self.amount_left_to_select.as_ref(), + &self.feerate, + self.warning.as_ref(), ) } } @@ -222,211 +386,6 @@ impl Recipient { } } -pub struct ChooseCoins { - descriptor: LianaDescriptor, - timelock: u16, - coins: Vec<(Coin, bool)>, - recipients: Vec<(Address, Amount)>, - - amount_left_to_select: Option, - feerate: form::Value, - generated: Option, - warning: Option, -} - -impl ChooseCoins { - pub fn new( - descriptor: LianaDescriptor, - coins: Vec, - timelock: u16, - blockheight: u32, - ) -> Self { - let mut coins: Vec<(Coin, bool)> = coins - .into_iter() - .filter_map(|c| { - if c.spend_info.is_none() { - Some((c, false)) - } else { - None - } - }) - .collect(); - coins.sort_by(|(a, _), (b, _)| { - if remaining_sequence(a, blockheight, timelock) - == remaining_sequence(b, blockheight, timelock) - { - // bigger amount first - b.amount.cmp(&a.amount) - } else { - // smallest blockheight (remaining_sequence) first - a.block_height.cmp(&b.block_height) - } - }); - Self { - descriptor, - timelock, - coins, - recipients: Vec::new(), - feerate: form::Value::default(), - generated: None, - warning: None, - amount_left_to_select: None, - } - } - - fn amount_left_to_select(&mut self) { - // We need the feerate in order to compute the required amount of BTC to - // select. Return early if we don't to not do unnecessary computation. - let feerate = match self.feerate.value.parse::() { - Ok(f) => f, - Err(_) => { - self.amount_left_to_select = None; - return; - } - }; - - // The coins to be included in this transaction. - let selected_coins: Vec<_> = self - .coins - .iter() - .filter_map(|(c, selected)| if *selected { Some(c) } else { None }) - .collect(); - - // A dummy representation of the transaction that will be computed, for - // the purpose of computing its size in order to anticipate the fees needed. - // NOTE: we make the conservative estimation a change output will always be - // needed. - let tx_template = bitcoin::Transaction { - version: 2, - lock_time: bitcoin::PackedLockTime(0), - input: selected_coins - .iter() - .map(|_| bitcoin::TxIn::default()) - .collect(), - output: self - .recipients - .iter() - .map(|(addr, amount)| bitcoin::TxOut { - script_pubkey: addr.script_pubkey(), - value: amount.to_sat(), - }) - .collect(), - }; - // nValue size + scriptPubKey CompactSize + OP_0 + PUSH32 + - const CHANGE_TXO_SIZE: usize = 8 + 1 + 1 + 1 + 32; - let satisfaction_vsize = self.descriptor.max_sat_weight() / 4; - let transaction_size = - tx_template.vsize() + satisfaction_vsize * tx_template.input.len() + CHANGE_TXO_SIZE; - - // Now the calculation of the amount left to be selected by the user is a simple - // substraction between the value needed by the transaction to be created and the - // value that was selected already. - let selected_amount = selected_coins.iter().map(|c| c.amount.to_sat()).sum(); - let output_sum: u64 = tx_template.output.iter().map(|o| o.value).sum(); - let needed_amount: u64 = transaction_size as u64 * feerate + output_sum; - self.amount_left_to_select = Some(Amount::from_sat( - needed_amount.saturating_sub(selected_amount), - )); - } -} - -impl Step for ChooseCoins { - fn load(&mut self, draft: &TransactionDraft) { - self.warning = None; - self.recipients = draft - .outputs - .iter() - .map(|(k, v)| (k.clone(), Amount::from_sat(*v))) - .collect(); - self.amount_left_to_select(); - } - - fn apply(&self, draft: &mut TransactionDraft) { - draft.inputs = self - .coins - .iter() - .filter_map(|(coin, selected)| if *selected { Some(*coin) } else { None }) - .collect(); - draft.generated = self.generated.clone(); - } - - fn update( - &mut self, - daemon: Arc, - _cache: &Cache, - draft: &TransactionDraft, - message: Message, - ) -> Command { - match message { - Message::View(view::Message::CreateSpend(view::CreateSpendMessage::FeerateEdited( - s, - ))) => { - if s.parse::().is_ok() { - self.feerate.value = s; - self.feerate.valid = true; - self.amount_left_to_select(); - } else if s.is_empty() { - self.feerate.value = "".to_string(); - self.feerate.valid = true; - self.amount_left_to_select = None; - } else { - self.feerate.valid = false; - self.amount_left_to_select = None; - } - self.warning = None; - } - Message::View(view::Message::CreateSpend(view::CreateSpendMessage::Generate)) => { - let inputs: Vec = self - .coins - .iter() - .filter_map( - |(coin, selected)| if *selected { Some(coin.outpoint) } else { None }, - ) - .collect(); - let outputs = draft.outputs.clone(); - let feerate_vb = self.feerate.value.parse::().unwrap_or(0); - self.warning = None; - return Command::perform( - async move { - daemon - .create_spend_tx(&inputs, &outputs, feerate_vb) - .map(|res| res.psbt) - .map_err(|e| e.into()) - }, - Message::Psbt, - ); - } - Message::Psbt(res) => match res { - Ok(psbt) => { - self.generated = Some(psbt); - return Command::perform(async {}, |_| Message::View(view::Message::Next)); - } - Err(e) => self.warning = Some(e), - }, - Message::View(view::Message::CreateSpend(view::CreateSpendMessage::SelectCoin(i))) => { - if let Some(coin) = self.coins.get_mut(i) { - coin.1 = !coin.1; - self.amount_left_to_select(); - } - } - _ => {} - } - - Command::none() - } - - fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> { - view::spend::step::choose_coins_view( - cache, - self.timelock, - &self.coins, - self.amount_left_to_select.as_ref(), - &self.feerate, - self.warning.as_ref(), - ) - } -} - pub struct SaveSpend { wallet: Arc, spend: Option, @@ -460,7 +419,6 @@ impl Step for SaveSpend { &mut self, daemon: Arc, cache: &Cache, - _draft: &TransactionDraft, message: Message, ) -> Command { if let Some(spend) = &mut self.spend { diff --git a/gui/src/app/view/spend/step.rs b/gui/src/app/view/spend/step.rs index 8d87565c..9fc5d720 100644 --- a/gui/src/app/view/spend/step.rs +++ b/gui/src/app/view/spend/step.rs @@ -1,14 +1,14 @@ -use iced::{Alignment, Length}; +use iced::{ + alignment, + widget::{checkbox, scrollable, Space}, + Alignment, Length, +}; use liana::miniscript::bitcoin::Amount; use liana_ui::{ color, - component::{ - amount::*, - badge, button, form, - text::{text, Text}, - }, + component::{amount::*, badge, button, form, text::*}, icon, theme, util::Collection, widget::*, @@ -18,67 +18,139 @@ use crate::{ app::{ cache::Cache, error::Error, - view::{coins, message::*, modal}, + menu::Menu, + view::{coins, dashboard, message::*}, }, daemon::model::{remaining_sequence, Coin}, }; -pub fn choose_recipients_view<'a>( +#[allow(clippy::too_many_arguments)] +pub fn create_spend_tx<'a>( + cache: &'a Cache, balance_available: &'a Amount, recipients: Vec>, total_amount: Amount, is_valid: bool, duplicate: bool, + timelock: u16, + coins: &[(Coin, bool)], + amount_left: Option<&Amount>, + feerate: &form::Value, + error: Option<&Error>, ) -> Element<'a, Message> { - modal( - false, - None, + dashboard( + &Menu::CreateSpendTx, + cache, + error, Column::new() - .push(text("Choose recipients").bold().size(50)) + .push(h3("Send")) .push( Column::new() .push(Column::with_children(recipients).spacing(10)) .push( - button::transparent(Some(icon::plus_icon()), "Add recipient") - .on_press(Message::CreateSpend(CreateSpendMessage::AddRecipient)), + Row::new() + .push_maybe(if duplicate { + Some( + Container::new( + text("Two recipient addresses are the same") + .style(color::RED), + ) + .padding(10), + ) + } else { + None + }) + .push(Space::with_width(Length::Fill)) + .push( + button::secondary(Some(icon::plus_icon()), "Add recipient") + .on_press(Message::CreateSpend( + CreateSpendMessage::AddRecipient, + )), + ), ) - .padding(10) - .max_width(1000) - .spacing(10), + .spacing(20), ) - .spacing(20) - .align_items(Alignment::Center), - Some( - Container::new( + .push( + Row::new() + .push( + Row::new() + .push(Container::new(p1_bold("Fee rate")).padding(10)) + .spacing(10) + .push( + form::Form::new("Feerate (sat/vbyte)", feerate, move |msg| { + Message::CreateSpend(CreateSpendMessage::FeerateEdited(msg)) + }) + .warning("Invalid feerate") + .size(20) + .padding(10), + ) + .width(Length::FillPortion(1)), + ) + .push(Space::with_width(Length::FillPortion(1))), + ) + .push( + Container::new( + Column::new() + .spacing(10) + .push( + Row::new() + .align_items(Alignment::Center) + .push(p1_bold("Coins selection").width(Length::Fill)) + .push(Container::new(if let Some(amount_left) = amount_left { + Row::new() + .spacing(5) + .push(amount_with_size(amount_left, P2_SIZE)) + .push(p2_regular("left to select").style(color::GREY_3)) + } else { + Row::new() + .push(text("Feerate needs to be set.").style(color::GREY_3)) + })) + .width(Length::Fill), + ) + .push( + Container::new(scrollable(coins.iter().enumerate().fold( + Column::new().spacing(10), + |col, (i, (coin, selected))| { + col.push(coin_list_view( + i, + coin, + timelock, + cache.blockheight as u32, + *selected, + )) + }, + ))) + .max_height(300), + ), + ) + .padding(20) + .style(theme::Card::Simple), + ) + .push( Row::new() .spacing(20) .align_items(Alignment::Center) + .push(Space::with_width(Length::Fill)) .push( - Container::new( - Row::new() - .align_items(Alignment::Center) - .spacing(5) - .push(text(format!("{}", total_amount)).bold()) - .push(text(format!("/ {}", balance_available))), - ) - .width(Length::Fill), + button::primary(None, "Clear") + .on_press(Message::Menu(Menu::CreateSpendTx)) + .width(Length::Units(100)), ) - .push_maybe(if duplicate { - Some(text("Two recipient addresses are the same").style(color::ORANGE)) - } else { - None - }) - .push(if is_valid && total_amount < *balance_available { - button::primary(None, "Next") - .on_press(Message::Next) - .width(Length::Units(100)) - } else { - button::primary(None, "Next").width(Length::Units(100)) - }), + .push( + if is_valid + && total_amount < *balance_available + && Some(&Amount::from_sat(0)) == amount_left + { + button::primary(None, "Next") + .on_press(Message::CreateSpend(CreateSpendMessage::Generate)) + .width(Length::Units(100)) + } else { + button::primary(None, "Next").width(Length::Units(100)) + }, + ), ) - .style(theme::Container::Foreground) - .padding(20), - ), + .push(Space::with_height(Length::Units(20))) + .spacing(20), ) } @@ -87,106 +159,60 @@ pub fn recipient_view<'a>( address: &form::Value, amount: &form::Value, ) -> Element<'a, CreateSpendMessage> { - Row::new() - .push( - form::Form::new("Address", address, move |msg| { - CreateSpendMessage::RecipientEdited(index, "address", msg) - }) - .warning("Invalid address (maybe it is for another network?)") - .size(20) - .padding(10), - ) - .push( - Container::new( - form::Form::new("Amount", amount, move |msg| { - CreateSpendMessage::RecipientEdited(index, "amount", msg) - }) - .warning("Invalid amount. Must be > 0.00005000 BTC.") - .size(20) - .padding(10), - ) - .width(Length::Units(300)), - ) - .spacing(5) - .push( - button::transparent(Some(icon::trash_icon()), "") - .on_press(CreateSpendMessage::DeleteRecipient(index)) - .width(Length::Shrink), - ) - .width(Length::Fill) - .into() -} - -pub fn choose_coins_view<'a>( - cache: &Cache, - timelock: u16, - coins: &[(Coin, bool)], - amount_left: Option<&Amount>, - feerate: &form::Value, - error: Option<&Error>, -) -> Element<'a, Message> { - modal( - true, - error, + Container::new( Column::new() - .push(text("Choose coins and feerate").bold().size(50)) + .spacing(10) .push( - Container::new( - form::Form::new("Feerate (sat/vbyte)", feerate, move |msg| { - Message::CreateSpend(CreateSpendMessage::FeerateEdited(msg)) - }) - .warning("Invalid feerate") - .size(20) - .padding(10), - ) - .width(Length::Units(250)), + Row::new().push(Space::with_width(Length::Fill)).push( + Button::new(icon::cross_icon()) + .style(theme::Button::Transparent) + .on_press(CreateSpendMessage::DeleteRecipient(index)) + .width(Length::Shrink), + ), ) .push( - Column::new() - .padding(10) - .spacing(10) - .push(coins.iter().enumerate().fold( - Column::new().spacing(10), - |col, (i, (coin, selected))| { - col.push(coin_list_view( - i, - coin, - timelock, - cache.blockheight as u32, - *selected, - )) - }, - )), - ) - .spacing(20) - .align_items(Alignment::Center), - Some( - Container::new( Row::new() - .align_items(Alignment::Center) + .align_items(Alignment::Start) + .spacing(10) .push( - Container::new(if let Some(amount_left) = amount_left { - Row::new() - .spacing(5) - .push(text("Amount left to select:")) - .push(text(amount_left.to_string()).bold()) - } else { - Row::new().push(text("Feerate needs to be set.")) - }) - .width(Length::Fill), + Container::new(p1_bold("Pay to")) + .align_x(alignment::Horizontal::Right) + .padding(10) + .width(Length::Units(80)), ) - .push(if Some(&Amount::from_sat(0)) == amount_left { - button::primary(None, "Next") - .on_press(Message::CreateSpend(CreateSpendMessage::Generate)) - .width(Length::Units(100)) - } else { - button::primary(None, "Next").width(Length::Units(100)) - }), + .push( + form::Form::new("Address", address, move |msg| { + CreateSpendMessage::RecipientEdited(index, "address", msg) + }) + .warning("Invalid address (maybe it is for another network?)") + .size(20) + .padding(10), + ), ) - .style(theme::Container::Foreground) - .padding(20), - ), + .push( + Row::new() + .align_items(Alignment::Start) + .spacing(10) + .push( + Container::new(p1_bold("Amount")) + .padding(10) + .align_x(alignment::Horizontal::Right) + .width(Length::Units(80)), + ) + .push( + form::Form::new("ex: 0.001", amount, move |msg| { + CreateSpendMessage::RecipientEdited(index, "amount", msg) + }) + .warning("Invalid amount. Must be > 0.00005000 BTC.") + .size(20) + .padding(10), + ) + .width(Length::Fill), + ), ) + .padding(20) + .style(theme::Card::Simple) + .into() } fn coin_list_view<'a>( @@ -196,37 +222,28 @@ fn coin_list_view<'a>( blockheight: u32, selected: bool, ) -> Element<'a, Message> { - Container::new( - Button::new( + Row::new() + .push( Row::new() - .push( - Row::new() - .push(if selected { - icon::square_check_icon() - } else { - icon::square_icon() - }) - .push(badge::coin()) - .push(if coin.spend_info.is_some() { - badge::spent() - } else if coin.block_height.is_none() { - badge::unconfirmed() - } else { - let seq = remaining_sequence(coin, blockheight, timelock); - coins::coin_sequence_label(seq, timelock as u32) - }) - .spacing(10) - .align_items(Alignment::Center) - .width(Length::Fill), - ) - .push(amount(&coin.amount)) + .push(checkbox("", selected, move |_| { + Message::CreateSpend(CreateSpendMessage::SelectCoin(i)) + })) + .push(if coin.spend_info.is_some() { + badge::spent() + } else if coin.block_height.is_none() { + badge::unconfirmed() + } else { + let seq = remaining_sequence(coin, blockheight, timelock); + coins::coin_sequence_label(seq, timelock as u32) + }) + .spacing(10) .align_items(Alignment::Center) - .spacing(20), + .width(Length::Fill), ) - .padding(10) - .on_press(Message::CreateSpend(CreateSpendMessage::SelectCoin(i))) - .style(theme::Button::TransparentBorder), - ) - .style(theme::Container::Card(theme::Card::Simple)) - .into() + .push(amount(&coin.amount)) + // give some space for the scroll bar without using padding + .push(Space::with_width(Length::Units(0))) + .align_items(Alignment::Center) + .spacing(20) + .into() } diff --git a/gui/ui/src/theme.rs b/gui/ui/src/theme.rs index 3a853720..f42c72cc 100644 --- a/gui/ui/src/theme.rs +++ b/gui/ui/src/theme.rs @@ -418,20 +418,30 @@ pub struct CheckBox {} impl checkbox::StyleSheet for Theme { type Style = CheckBox; - fn active(&self, _style: &Self::Style, _is_selected: bool) -> checkbox::Appearance { - checkbox::Appearance { - background: iced::Color::TRANSPARENT.into(), - border_width: 1.0, - border_color: color::GREY_7, - checkmark_color: color::GREEN, - text_color: None, - border_radius: 0.0, + fn active(&self, _style: &Self::Style, is_selected: bool) -> checkbox::Appearance { + if is_selected { + checkbox::Appearance { + background: color::GREEN.into(), + border_width: 0.0, + border_color: iced::Color::TRANSPARENT, + checkmark_color: color::GREY_4, + text_color: None, + border_radius: 4.0, + } + } else { + checkbox::Appearance { + background: color::GREY_4.into(), + border_width: 0.0, + border_color: iced::Color::TRANSPARENT, + checkmark_color: color::GREEN, + text_color: None, + border_radius: 4.0, + } } } fn hovered(&self, style: &Self::Style, is_selected: bool) -> checkbox::Appearance { - let active = self.active(style, is_selected); - checkbox::Appearance { ..active } + self.active(style, is_selected) } } From 330f7e65bb0bf48d8ede73bbb2295aee99f1eed0 Mon Sep 17 00:00:00 2001 From: edouard Date: Tue, 25 Apr 2023 12:42:28 +0200 Subject: [PATCH 2/4] gui: add module psbt --- gui/src/app/state/mod.rs | 1 + .../app/state/{spend/detail.rs => psbt.rs} | 20 +- gui/src/app/state/psbts.rs | 6 +- gui/src/app/state/recovery.rs | 6 +- gui/src/app/state/spend/mod.rs | 1 - gui/src/app/state/spend/step.rs | 12 +- gui/src/app/view/mod.rs | 1 + gui/src/app/view/{spend/detail.rs => pbst.rs} | 21 +- gui/src/app/view/psbt.rs | 836 ++++++++++++++++++ gui/src/app/view/psbts.rs | 23 +- gui/src/app/view/spend/mod.rs | 283 +++++- gui/src/app/view/spend/step.rs | 249 ------ gui/src/app/view/transactions.rs | 2 +- 13 files changed, 1166 insertions(+), 295 deletions(-) rename gui/src/app/state/{spend/detail.rs => psbt.rs} (97%) rename gui/src/app/view/{spend/detail.rs => pbst.rs} (98%) create mode 100644 gui/src/app/view/psbt.rs delete mode 100644 gui/src/app/view/spend/step.rs diff --git a/gui/src/app/state/mod.rs b/gui/src/app/state/mod.rs index 5e1867f1..c7170b61 100644 --- a/gui/src/app/state/mod.rs +++ b/gui/src/app/state/mod.rs @@ -1,4 +1,5 @@ mod coins; +mod psbt; mod psbts; mod recovery; mod settings; diff --git a/gui/src/app/state/spend/detail.rs b/gui/src/app/state/psbt.rs similarity index 97% rename from gui/src/app/state/spend/detail.rs rename to gui/src/app/state/psbt.rs index 990c36b7..d6d2a832 100644 --- a/gui/src/app/state/spend/detail.rs +++ b/gui/src/app/state/psbt.rs @@ -21,7 +21,6 @@ use crate::{ error::Error, message::Message, view, - view::spend::detail, wallet::{Wallet, WalletError}, }, daemon::{ @@ -49,7 +48,7 @@ trait Action { fn view(&self) -> Element; } -pub struct SpendTxState { +pub struct PsbtState { wallet: Arc, desc_policy: LianaPolicy, tx: SpendTx, @@ -57,7 +56,7 @@ pub struct SpendTxState { action: Option>, } -impl SpendTxState { +impl PsbtState { pub fn new(wallet: Arc, tx: SpendTx, saved: bool) -> Self { Self { desc_policy: wallet.main_descriptor.policy(), @@ -130,7 +129,8 @@ impl SpendTxState { } pub fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> { - let content = detail::spend_view( + let content = view::psbt::spend_view( + cache, &self.tx, self.saved, &self.desc_policy, @@ -178,7 +178,7 @@ impl Action for SaveAction { Command::none() } fn view(&self) -> Element { - detail::save_action(self.error.as_ref(), self.saved) + view::psbt::save_action(self.error.as_ref(), self.saved) } } @@ -221,7 +221,7 @@ impl Action for BroadcastAction { Command::none() } fn view(&self) -> Element { - detail::broadcast_action(self.error.as_ref(), self.broadcast) + view::psbt::broadcast_action(self.error.as_ref(), self.broadcast) } } @@ -261,7 +261,7 @@ impl Action for DeleteAction { Command::none() } fn view(&self) -> Element { - detail::delete_action(self.error.as_ref(), self.deleted) + view::psbt::delete_action(self.error.as_ref(), self.deleted) } } @@ -376,7 +376,7 @@ impl Action for SignAction { Command::none() } fn view(&self) -> Element { - view::spend::detail::sign_action( + view::psbt::sign_action( self.error.as_ref(), &self.hws, self.wallet.signer.as_ref().map(|s| s.fingerprint()), @@ -439,9 +439,9 @@ impl UpdateAction { impl Action for UpdateAction { fn view(&self) -> Element { if self.success { - view::spend::detail::update_spend_success_view() + view::psbt::update_spend_success_view() } else { - view::spend::detail::update_spend_view( + view::psbt::update_spend_view( self.psbt.clone(), &self.updated, self.error.as_ref(), diff --git a/gui/src/app/state/psbts.rs b/gui/src/app/state/psbts.rs index 73f6a413..e6ed2675 100644 --- a/gui/src/app/state/psbts.rs +++ b/gui/src/app/state/psbts.rs @@ -8,7 +8,7 @@ use liana_ui::{ widget::Element, }; -use super::{spend::detail, State}; +use super::{psbt, State}; use crate::{ app::{cache::Cache, error::Error, menu::Menu, message::Message, view, wallet::Wallet}, daemon::{model::SpendTx, Daemon}, @@ -16,7 +16,7 @@ use crate::{ pub struct PsbtsPanel { wallet: Arc, - selected_tx: Option, + selected_tx: Option, spend_txs: Vec, warning: Option, import_tx: Option, @@ -90,7 +90,7 @@ impl State for PsbtsPanel { } Message::View(view::Message::Select(i)) => { if let Some(tx) = self.spend_txs.get(i) { - let tx = detail::SpendTxState::new(self.wallet.clone(), tx.clone(), true); + let tx = psbt::PsbtState::new(self.wallet.clone(), tx.clone(), true); let cmd = tx.load(daemon); self.selected_tx = Some(tx); return cmd; diff --git a/gui/src/app/state/recovery.rs b/gui/src/app/state/recovery.rs index 05f39b55..7d62568b 100644 --- a/gui/src/app/state/recovery.rs +++ b/gui/src/app/state/recovery.rs @@ -12,7 +12,7 @@ use crate::{ error::Error, menu::Menu, message::Message, - state::spend::detail, + state::psbt, state::{redirect, State}, view, wallet::Wallet, @@ -32,7 +32,7 @@ pub struct RecoveryPanel { warning: Option, feerate: form::Value, recipient: form::Value, - generated: Option, + generated: Option, } impl RecoveryPanel { @@ -102,7 +102,7 @@ impl State for RecoveryPanel { }, Message::Recovery(res) => match res { Ok(tx) => { - self.generated = Some(detail::SpendTxState::new(self.wallet.clone(), tx, false)) + self.generated = Some(psbt::PsbtState::new(self.wallet.clone(), tx, false)) } Err(e) => self.warning = Some(e), }, diff --git a/gui/src/app/state/spend/mod.rs b/gui/src/app/state/spend/mod.rs index fda7f111..ce4ccc6e 100644 --- a/gui/src/app/state/spend/mod.rs +++ b/gui/src/app/state/spend/mod.rs @@ -1,4 +1,3 @@ -pub mod detail; mod step; use std::sync::Arc; diff --git a/gui/src/app/state/spend/step.rs b/gui/src/app/state/spend/step.rs index a85a792d..206c9be9 100644 --- a/gui/src/app/state/spend/step.rs +++ b/gui/src/app/state/spend/step.rs @@ -13,9 +13,7 @@ use liana::{ use liana_ui::{component::form, widget::Element}; use crate::{ - app::{ - cache::Cache, error::Error, message::Message, state::spend::detail, view, wallet::Wallet, - }, + app::{cache::Cache, error::Error, message::Message, state::psbt, view, wallet::Wallet}, daemon::{ model::{remaining_sequence, Coin, SpendTx}, Daemon, @@ -286,7 +284,7 @@ impl Step for DefineSpend { } fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> { - view::spend::step::create_spend_tx( + view::spend::create_spend_tx( cache, &self.balance_available, self.recipients @@ -382,13 +380,13 @@ impl Recipient { } fn view(&self, i: usize) -> Element { - view::spend::step::recipient_view(i, &self.address, &self.amount) + view::spend::recipient_view(i, &self.address, &self.amount) } } pub struct SaveSpend { wallet: Arc, - spend: Option, + spend: Option, } impl SaveSpend { @@ -408,7 +406,7 @@ impl Step for SaveSpend { .main_descriptor .partial_spend_info(&psbt) .unwrap(); - self.spend = Some(detail::SpendTxState::new( + self.spend = Some(psbt::PsbtState::new( self.wallet.clone(), SpendTx::new(None, psbt, draft.inputs.clone(), sigs), false, diff --git a/gui/src/app/view/mod.rs b/gui/src/app/view/mod.rs index b2e957f9..c7c3d787 100644 --- a/gui/src/app/view/mod.rs +++ b/gui/src/app/view/mod.rs @@ -4,6 +4,7 @@ mod warning; pub mod coins; pub mod home; pub mod hw; +pub mod psbt; pub mod psbts; pub mod receive; pub mod recovery; diff --git a/gui/src/app/view/spend/detail.rs b/gui/src/app/view/pbst.rs similarity index 98% rename from gui/src/app/view/spend/detail.rs rename to gui/src/app/view/pbst.rs index 70925c60..32850458 100644 --- a/gui/src/app/view/spend/detail.rs +++ b/gui/src/app/view/pbst.rs @@ -29,22 +29,25 @@ use liana_ui::{ use crate::{ app::{ + cache::Cache, error::Error, - view::{hw::hw_list_view, message::*, warning::warn}, + menu::Menu, + view::{dashboard, hw::hw_list_view, message::*, warning::warn}, }, daemon::model::{Coin, SpendStatus, SpendTx}, hw::HardwareWallet, }; -pub fn spend_view<'a>( +pub fn psbt_view<'a>( + cache: &'a Cache, tx: &'a SpendTx, - saved: bool, desc_info: &'a LianaPolicy, key_aliases: &'a HashMap, network: Network, ) -> Element<'a, Message> { - spend_modal( - saved, + dashboard( + &Menu::PSBTs, + &cache, None, Column::new() .align_items(Alignment::Center) @@ -197,7 +200,7 @@ pub fn spend_modal<'a, T: Into>>( .into() } -fn spend_header<'a>(tx: &SpendTx) -> Element<'a, Message> { +pub fn spend_header<'a>(tx: &SpendTx) -> Element<'a, Message> { Column::new() .spacing(20) .align_items(Alignment::Center) @@ -233,7 +236,7 @@ fn spend_header<'a>(tx: &SpendTx) -> Element<'a, Message> { .into() } -fn spend_overview_view<'a>( +pub fn spend_overview_view<'a>( tx: &'a SpendTx, desc_info: &'a LianaPolicy, key_aliases: &'a HashMap, @@ -561,7 +564,7 @@ pub fn inputs_and_outputs_view<'a>( coins .iter() .fold(Column::new(), |col: Column<'a, Message>, coin| { - col.push(separation().width(Length::Fill)).push( + col.push( Row::new() .padding(15) .align_items(Alignment::Center) @@ -641,7 +644,7 @@ pub fn inputs_and_outputs_view<'a>( .enumerate() .fold(Column::new(), |col: Column<'a, Message>, (i, output)| { let addr = Address::from_script(&output.script_pubkey, network).unwrap(); - col.push(separation().width(Length::Fill)).push( + col.push( Column::new() .padding(15) .width(Length::Fill) diff --git a/gui/src/app/view/psbt.rs b/gui/src/app/view/psbt.rs new file mode 100644 index 00000000..9d2a7731 --- /dev/null +++ b/gui/src/app/view/psbt.rs @@ -0,0 +1,836 @@ +use std::collections::{HashMap, HashSet}; + +use iced::{ + widget::{scrollable, tooltip, Space}, + Alignment, Length, +}; + +use liana::{ + descriptors::{LianaPolicy, PathInfo, PathSpendInfo}, + miniscript::bitcoin::{ + util::bip32::{DerivationPath, Fingerprint}, + Address, Amount, Network, Transaction, + }, +}; + +use liana_ui::{ + color, + component::{ + amount::*, + badge, button, card, + collapse::Collapse, + form, hw, separation, + text::{text, Text}, + }, + icon, theme, + util::Collection, + widget::*, +}; + +use crate::{ + app::{ + cache::Cache, + error::Error, + view::{dashboard, hw::hw_list_view, message::*, warning::warn}, + }, + daemon::model::{Coin, SpendStatus, SpendTx}, + hw::HardwareWallet, +}; + +pub fn spend_view<'a>( + cache: &'a Cache, + tx: &'a SpendTx, + _saved: bool, + desc_info: &'a LianaPolicy, + key_aliases: &'a HashMap, + network: Network, +) -> Element<'a, Message> { + dashboard( + &crate::app::menu::Menu::CreateSpendTx, + &cache, + None, + Column::new() + .align_items(Alignment::Center) + .spacing(20) + .push(spend_header(tx)) + .push(spend_overview_view(tx, desc_info, key_aliases)) + .push(inputs_and_outputs_view( + &tx.coins, + &tx.psbt.unsigned_tx, + network, + Some(tx.change_indexes.clone()), + None, + )), + ) +} + +pub fn save_action<'a>(warning: Option<&Error>, saved: bool) -> Element<'a, Message> { + if saved { + card::simple(text("Transaction is saved")) + .width(Length::Units(400)) + .align_x(iced::alignment::Horizontal::Center) + .into() + } else { + card::simple( + Column::new() + .spacing(10) + .push_maybe(warning.map(|w| warn(Some(w)))) + .push(text("Save the transaction as draft")) + .push( + Row::new() + .push(Column::new().width(Length::Fill)) + .push(button::alert(None, "Ignore").on_press(Message::Close)) + .push( + button::primary(None, "Save") + .on_press(Message::Spend(SpendTxMessage::Confirm)), + ), + ), + ) + .width(Length::Units(400)) + .into() + } +} + +pub fn broadcast_action<'a>(warning: Option<&Error>, saved: bool) -> Element<'a, Message> { + if saved { + card::simple(text("Transaction is broadcast")) + .width(Length::Units(400)) + .align_x(iced::alignment::Horizontal::Center) + .into() + } else { + card::simple( + Column::new() + .spacing(10) + .push_maybe(warning.map(|w| warn(Some(w)))) + .push(text("Broadcast the transaction")) + .push( + Row::new().push(Column::new().width(Length::Fill)).push( + button::primary(None, "Broadcast") + .on_press(Message::Spend(SpendTxMessage::Confirm)), + ), + ), + ) + .width(Length::Units(400)) + .into() + } +} + +pub fn delete_action<'a>(warning: Option<&Error>, deleted: bool) -> Element<'a, Message> { + if deleted { + card::simple( + Column::new() + .spacing(20) + .align_items(Alignment::Center) + .push(text("Transaction is deleted")) + .push(button::primary(None, "Go back to drafts").on_press(Message::Close)), + ) + .align_x(iced::alignment::Horizontal::Center) + .width(Length::Units(400)) + .into() + } else { + card::simple( + Column::new() + .spacing(10) + .push_maybe(warning.map(|w| warn(Some(w)))) + .push(text("Delete the transaction draft")) + .push( + Row::new() + .push(Column::new().width(Length::Fill)) + .push( + button::transparent(None, "Cancel") + .on_press(Message::Spend(SpendTxMessage::Cancel)), + ) + .push( + button::alert(None, "Delete") + .on_press(Message::Spend(SpendTxMessage::Confirm)), + ), + ), + ) + .width(Length::Units(400)) + .into() + } +} + +pub fn spend_modal<'a, T: Into>>( + saved: bool, + warning: Option<&Error>, + content: T, +) -> Element<'a, Message> { + Column::new() + .push(warn(warning)) + .push( + Container::new( + Row::new() + .push(if saved { + Column::new() + .push( + button::alert(Some(icon::trash_icon()), "Delete") + .on_press(Message::Spend(SpendTxMessage::Delete)), + ) + .width(Length::Fill) + } else { + Column::new() + .push( + button::transparent(None, "< Previous").on_press(Message::Previous), + ) + .width(Length::Fill) + }) + .align_items(iced::Alignment::Center) + .push(if saved { + button::primary(Some(icon::cross_icon()), "Close").on_press(Message::Close) + } else { + button::primary(Some(icon::cross_icon()), "Close") + .on_press(Message::Spend(SpendTxMessage::Save)) + }), + ) + .padding(10) + .style(theme::Container::Background), + ) + .push( + Container::new(scrollable( + Container::new(Container::new(content).max_width(800)) + .width(Length::Fill) + .center_x(), + )) + .height(Length::Fill) + .style(theme::Container::Background), + ) + .width(Length::Fill) + .height(Length::Fill) + .into() +} + +pub fn spend_header<'a>(tx: &SpendTx) -> Element<'a, Message> { + Column::new() + .spacing(20) + .align_items(Alignment::Center) + .push( + Row::new() + .push(badge::Badge::new(icon::send_icon()).style(theme::Badge::Standard)) + .push(if !tx.sigs.recovery_paths().is_empty() { + text("Recovery").bold() + } else if tx.spend_amount == Amount::from_sat(0) { + text("Self send").bold() + } else { + text("Spend").bold() + }) + .spacing(5) + .align_items(Alignment::Center), + ) + .push_maybe(match tx.status { + SpendStatus::Deprecated => Some(badge::deprecated()), + SpendStatus::Broadcast => Some(badge::unconfirmed()), + SpendStatus::Spent => Some(badge::spent()), + _ => None, + }) + .push( + Column::new() + .align_items(Alignment::Center) + .push(amount_with_size(&tx.spend_amount, 50)) + .push( + Row::new() + .push(text("Miner fee: ")) + .push(amount(&tx.fee_amount)), + ), + ) + .into() +} + +pub fn spend_overview_view<'a>( + tx: &'a SpendTx, + desc_info: &'a LianaPolicy, + key_aliases: &'a HashMap, +) -> Element<'a, Message> { + Container::new( + Column::new() + .push( + Column::new() + .padding(15) + .spacing(10) + .push( + Row::new() + .align_items(Alignment::Center) + .push(text("PSBT:").bold().width(Length::Fill)) + .push( + Row::new() + .spacing(5) + .push( + button::secondary(Some(icon::clipboard_icon()), "Copy") + .on_press(Message::Clipboard(tx.psbt.to_string())), + ) + .push( + button::secondary(Some(icon::import_icon()), "Update") + .on_press(Message::Spend(SpendTxMessage::EditPsbt)), + ), + ) + .align_items(Alignment::Center), + ) + .push( + Row::new() + .push(text("Tx ID:").bold().width(Length::Fill)) + .push(text(tx.psbt.unsigned_tx.txid().to_string()).small()) + .push( + Button::new(icon::clipboard_icon()) + .on_press(Message::Clipboard( + tx.psbt.unsigned_tx.txid().to_string(), + )) + .style(theme::Button::TransparentBorder), + ) + .align_items(Alignment::Center), + ), + ) + .push(signatures(tx, desc_info, key_aliases)), + ) + .style(theme::Container::Card(theme::Card::Simple)) + .into() +} + +pub fn signatures<'a>( + tx: &'a SpendTx, + desc_info: &'a LianaPolicy, + keys_aliases: &'a HashMap, +) -> Element<'a, Message> { + Column::new() + .push( + if let Some(sigs) = tx.path_ready() { + Container::new( + scrollable( + Row::new() + .spacing(5) + .align_items(Alignment::Center) + .push(icon::circle_check_icon().style(color::GREEN)) + .push(text("Ready").bold().style(color::GREEN)) + .push(text(", signed by")) + .push( + sigs.signed_pubkeys + .keys() + .fold(Row::new().spacing(5), |row, value| { + row.push(if let Some(alias) = keys_aliases.get(&value.0) { + Container::new( + tooltip::Tooltip::new( + Container::new(text(alias)) + .padding(3) + .style(theme::Container::Pill(theme::Pill::Simple)), + value.0.to_string(), + tooltip::Position::Bottom, + ) + .style(theme::Container::Card(theme::Card::Simple)), + ) + } else { + Container::new(text(value.0.to_string())) + .padding(3) + .style(theme::Container::Pill(theme::Pill::Simple)) + }) + }), + ) + ).horizontal_scroll(scrollable::Properties::new().width(2).scroller_width(2)) + ).padding(15) + } else{ + Container::new( + Collapse::new( + move || { + Button::new( + Row::new() + .align_items(Alignment::Center) + .push(Row::new() + .spacing(5) + .align_items(Alignment::Center) + .push(icon::circle_cross_icon()) + .push(text("Not ready").bold()) + .width(Length::Fill) + ) + .push(icon::collapse_icon()), + ) + .padding(15) + .width(Length::Fill) + .style(theme::Button::TransparentBorder) + }, + move || { + Button::new( + Row::new() + .align_items(Alignment::Center) + .push( + Row::new() + .spacing(5) + .align_items(Alignment::Center) + .push(icon::circle_cross_icon()) + .push(text("Not ready").bold()) + .width(Length::Fill) + ) + .push(icon::collapsed_icon()), + ) + .padding(15) + .width(Length::Fill) + .style(theme::Button::TransparentBorder) + }, + move || { + Into::>::into( + Column::new().push(separation().width(Length::Fill)).push( + Column::new() + .padding(15) + .spacing(10) + .push(text(if !tx.sigs.recovery_paths().is_empty() { + "Multiple spending paths available. Finalizing this transaction requires either:" + } else { + "1 spending path available. Finalizing this transaction requires:" + })) + .push(path_view( + desc_info.primary_path(), + tx.sigs.primary_path(), + keys_aliases, + )) + .push(tx.sigs.recovery_paths().iter().fold(Column::new().spacing(10), |col, (seq, path)| { + let keys = &desc_info.recovery_paths()[seq]; + col.push(path_view(keys, path, keys_aliases)) + })), + ), + ) + }, + ))}) + .push_maybe(if tx.status == SpendStatus::Pending { + Some( + Column::new().push(separation().width(Length::Fill)).push( + Container::new( + Row::new() + .push(Space::with_width(Length::Fill)) + .push_maybe(if tx.path_ready().is_none() { + Some( + button::primary(None, "Sign") + .on_press(Message::Spend(SpendTxMessage::Sign)) + .width(Length::Units(150)), + ) + } else { + Some( + button::primary(None, "Broadcast") + .on_press(Message::Spend(SpendTxMessage::Broadcast)) + .width(Length::Units(150)), + ) + }) + .align_items(Alignment::Center) + .spacing(20), + ) + .padding(15), + ), + ) + } else { + None + }) + .into() +} + +pub fn path_view<'a>( + path: &'a PathInfo, + sigs: &'a PathSpendInfo, + key_aliases: &'a HashMap, +) -> Element<'a, Message> { + let mut keys: Vec<(Fingerprint, DerivationPath)> = + path.thresh_origins().1.into_iter().collect(); + let missing_signatures = if sigs.sigs_count >= sigs.threshold { + 0 + } else { + sigs.threshold - sigs.sigs_count + }; + keys.sort(); + scrollable( + Row::new() + .align_items(Alignment::Center) + .push(if sigs.sigs_count >= sigs.threshold { + icon::circle_check_icon().style(color::GREEN) + } else { + icon::circle_cross_icon() + }) + .push(text(format!(" {}", missing_signatures)).bold()) + .push(text(format!( + " more signature{}", + if missing_signatures > 1 { + "s from " + } else if missing_signatures == 0 { + "" + } else { + " from " + } + ))) + .push_maybe(if keys.is_empty() { + None + } else { + Some(keys.iter().fold(Row::new().spacing(5), |row, value| { + row.push_maybe(if !sigs.signed_pubkeys.contains_key(value) { + Some(if let Some(alias) = key_aliases.get(&value.0) { + Container::new( + tooltip::Tooltip::new( + Container::new(text(alias)) + .padding(3) + .style(theme::Container::Pill(theme::Pill::Simple)), + value.0.to_string(), + tooltip::Position::Bottom, + ) + .style(theme::Container::Card(theme::Card::Simple)), + ) + } else { + Container::new(text(value.0.to_string())) + .padding(3) + .style(theme::Container::Pill(theme::Pill::Simple)) + }) + } else { + None + }) + })) + }) + .push_maybe(if sigs.signed_pubkeys.is_empty() { + None + } else { + Some(text(", already signed by ")) + }) + .push( + sigs.signed_pubkeys + .keys() + .fold(Row::new().spacing(5), |row, value| { + row.push(if let Some(alias) = key_aliases.get(&value.0) { + Container::new( + tooltip::Tooltip::new( + Container::new(text(alias)) + .padding(3) + .style(theme::Container::Pill(theme::Pill::Simple)), + value.0.to_string(), + tooltip::Position::Bottom, + ) + .style(theme::Container::Card(theme::Card::Simple)), + ) + } else { + Container::new(text(value.0.to_string())) + .padding(3) + .style(theme::Container::Pill(theme::Pill::Simple)) + }) + }), + ), + ) + .horizontal_scroll(scrollable::Properties::new().width(2).scroller_width(2)) + .into() +} + +pub fn inputs_and_outputs_view<'a>( + coins: &'a [Coin], + tx: &'a Transaction, + network: Network, + change_indexes: Option>, + receive_indexes: Option>, +) -> Element<'a, Message> { + Column::new() + .push( + Column::new() + .spacing(10) + .push_maybe(if !coins.is_empty() { + Some( + Container::new(Collapse::new( + move || { + Button::new( + Row::new() + .align_items(Alignment::Center) + .push( + text(format!( + "{} spent coin{}", + coins.len(), + if coins.len() == 1 { "" } else { "s" } + )) + .bold() + .width(Length::Fill), + ) + .push(icon::collapse_icon()), + ) + .padding(15) + .width(Length::Fill) + .style(theme::Button::TransparentBorder) + }, + move || { + Button::new( + Row::new() + .align_items(Alignment::Center) + .push( + text(format!( + "{} spent coin{}", + coins.len(), + if coins.len() == 1 { "" } else { "s" } + )) + .bold() + .width(Length::Fill), + ) + .push(icon::collapsed_icon()), + ) + .padding(15) + .width(Length::Fill) + .style(theme::Button::TransparentBorder) + }, + move || { + coins + .iter() + .fold(Column::new(), |col: Column<'a, Message>, coin| { + col.push( + Row::new() + .padding(15) + .align_items(Alignment::Center) + .width(Length::Fill) + .push( + Row::new() + .width(Length::Fill) + .align_items(Alignment::Center) + .push( + text(coin.outpoint.to_string()) + .small() + ) + .push( + Button::new(icon::clipboard_icon()) + .on_press(Message::Clipboard( + coin.outpoint.to_string(), + )) + .style( + theme::Button::TransparentBorder, + ), + ), + ) + .push(amount(&coin.amount)), + ) + }) + .into() + }, + )) + .style(theme::Container::Card(theme::Card::Simple)), + ) + } else { + None + }) + .push( + Container::new(Collapse::new( + move || { + Button::new( + Row::new() + .align_items(Alignment::Center) + .push( + text(format!( + "{} recipient{}", + tx.output.len(), + if tx.output.len() == 1 { "" } else { "s" } + )) + .bold() + .width(Length::Fill), + ) + .push(icon::collapse_icon()), + ) + .padding(15) + .width(Length::Fill) + .style(theme::Button::TransparentBorder) + }, + move || { + Button::new( + Row::new() + .align_items(Alignment::Center) + .push( + text(format!( + "{} recipient{}", + tx.output.len(), + if tx.output.len() == 1 { "" } else { "s" } + )) + .bold() + .width(Length::Fill), + ) + .push(icon::collapsed_icon()), + ) + .padding(15) + .width(Length::Fill) + .style(theme::Button::TransparentBorder) + }, + move || { + tx.output + .iter() + .enumerate() + .fold(Column::new(), |col: Column<'a, Message>, (i, output)| { + let addr = Address::from_script(&output.script_pubkey, network).unwrap(); + col.push( + Column::new() + .padding(15) + .width(Length::Fill) + .spacing(10) + .push( + Row::new() + .width(Length::Fill) + .push( + Row::new() + .align_items(Alignment::Center) + .width(Length::Fill) + .push(text(addr.to_string()).small()) + .push( + Button::new(icon::clipboard_icon()) + .on_press(Message::Clipboard( + addr.to_string(), + )) + .style( + theme::Button::TransparentBorder, + ), + ), + ) + .push( + amount(&Amount::from_sat(output.value)) + ), + ) + .push_maybe( + if let Some(indexes) = change_indexes.as_ref() { + if indexes.contains(&i) { + Some( + Container::new(text("Change")) + .padding(5) + .style(theme::Container::Pill(theme::Pill::Success)), + ) + } else { + None + } + } else { + None + }, + ) + .push_maybe( + if let Some(indexes) = receive_indexes.as_ref() { + if indexes.contains(&i) { + Some( + Container::new(text("Deposit")) + .padding(5) + .style(theme::Container::Pill(theme::Pill::Success)), + ) + } else { + None + } + } else { + None + }, + ), + ) + }) + .into() + }, + )) + .style(theme::Container::Card(theme::Card::Simple)), + ), + ) + .into() +} + +pub fn sign_action<'a>( + warning: Option<&Error>, + hws: &'a [HardwareWallet], + signer: Option, + signer_alias: Option<&'a String>, + processing: bool, + chosen_hw: Option, + signed: &HashSet, +) -> Element<'a, Message> { + Column::new() + .push_maybe(warning.map(|w| warn(Some(w)))) + .push(card::simple( + Column::new() + .push( + Column::new() + .push( + Row::new() + .push( + text("Select signing device to sign with:") + .bold() + .width(Length::Fill), + ) + .push(button::secondary(None, "Refresh").on_press(Message::Reload)) + .align_items(Alignment::Center), + ) + .spacing(10) + .push(hws.iter().enumerate().fold( + Column::new().spacing(10), + |col, (i, hw)| { + col.push(hw_list_view( + i, + hw, + Some(i) == chosen_hw, + processing, + hw.fingerprint() + .map(|f| signed.contains(&f)) + .unwrap_or(false), + )) + }, + )) + .push_maybe(signer.map(|fingerprint| { + Button::new(if signed.contains(&fingerprint) { + hw::sign_success_hot_signer(fingerprint, signer_alias) + } else { + hw::hot_signer(fingerprint, signer_alias) + }) + .on_press(Message::Spend(SpendTxMessage::SelectHotSigner)) + .padding(10) + .style(theme::Button::Border) + .width(Length::Fill) + })) + .width(Length::Fill), + ) + .spacing(20) + .width(Length::Fill) + .align_items(Alignment::Center), + )) + .width(Length::Units(500)) + .into() +} + +pub fn update_spend_view<'a>( + psbt: String, + updated: &form::Value, + error: Option<&Error>, + processing: bool, +) -> Element<'a, Message> { + Column::new() + .push(warn(error)) + .push(card::simple( + Column::new() + .spacing(20) + .push( + Row::new() + .push(text("PSBT:").bold().width(Length::Fill)) + .push( + button::border(Some(icon::clipboard_icon()), "Copy") + .on_press(Message::Clipboard(psbt)), + ) + .align_items(Alignment::Center), + ) + .push(separation().width(Length::Fill)) + .push( + Column::new() + .spacing(10) + .push(text("Insert updated PSBT:").bold()) + .push( + form::Form::new("PSBT", updated, move |msg| { + Message::ImportSpend(ImportSpendMessage::PsbtEdited(msg)) + }) + .warning("Please enter the correct base64 encoded PSBT") + .size(20) + .padding(10), + ) + .push(Row::new().push(Space::with_width(Length::Fill)).push( + if updated.valid && !updated.value.is_empty() && !processing { + button::primary(None, "Update") + .on_press(Message::ImportSpend(ImportSpendMessage::Confirm)) + } else if processing { + button::primary(None, "Processing...") + } else { + button::primary(None, "Update") + }, + )), + ), + )) + .max_width(400) + .into() +} + +pub fn update_spend_success_view<'a>() -> Element<'a, Message> { + Column::new() + .push( + card::simple(Container::new( + text("Spend transaction is updated").style(color::GREEN), + )) + .padding(50), + ) + .width(Length::Units(400)) + .align_items(Alignment::Center) + .into() +} diff --git a/gui/src/app/view/psbts.rs b/gui/src/app/view/psbts.rs index 6c187fb1..214a0739 100644 --- a/gui/src/app/view/psbts.rs +++ b/gui/src/app/view/psbts.rs @@ -108,16 +108,19 @@ fn spend_tx_list_view<'a>(i: usize, tx: &SpendTx) -> Element<'a, Message> { Row::new() .spacing(5) .align_items(Alignment::Center) - .push(text(format!( - "{}/{}", - if sigs.sigs_count <= sigs.threshold { - sigs.sigs_count - } else { + .push( + p2_regular(format!( + "{}/{}", + if sigs.sigs_count <= sigs.threshold { + sigs.sigs_count + } else { + sigs.threshold + }, sigs.threshold - }, - sigs.threshold - ))) - .push(icon::key_icon()) + )) + .style(color::GREY_3), + ) + .push(icon::key_icon().style(color::GREY_3)) }) .spacing(10) .align_items(Alignment::Center) @@ -132,7 +135,7 @@ fn spend_tx_list_view<'a>(i: usize, tx: &SpendTx) -> Element<'a, Message> { .push( Column::new() .push(amount(&tx.spend_amount)) - .push(text(format!("fee: {:8}", tx.fee_amount.to_btc())).small()) + .push(amount_with_size(&tx.fee_amount, P2_SIZE)) .width(Length::Shrink), ) .align_items(Alignment::Center) diff --git a/gui/src/app/view/spend/mod.rs b/gui/src/app/view/spend/mod.rs index 0536ceab..8dd63617 100644 --- a/gui/src/app/view/spend/mod.rs +++ b/gui/src/app/view/spend/mod.rs @@ -1,2 +1,281 @@ -pub mod detail; -pub mod step; +use std::collections::HashMap; + +use iced::{ + alignment, + widget::{checkbox, scrollable, Space}, + Alignment, Length, +}; + +use liana::{ + descriptors::LianaPolicy, + miniscript::bitcoin::{util::bip32::Fingerprint, Amount, Network}, +}; + +use liana_ui::{ + color, + component::{amount::*, badge, button, form, text::*}, + icon, theme, + util::Collection, + widget::*, +}; + +use crate::{ + app::{ + cache::Cache, + error::Error, + menu::Menu, + view::{coins, dashboard, message::*, psbt}, + }, + daemon::model::{remaining_sequence, Coin, SpendTx}, +}; + +pub fn spend_view<'a>( + cache: &'a Cache, + tx: &'a SpendTx, + _saved: bool, + desc_info: &'a LianaPolicy, + key_aliases: &'a HashMap, + network: Network, +) -> Element<'a, Message> { + dashboard( + &Menu::CreateSpendTx, + &cache, + None, + Column::new() + .align_items(Alignment::Center) + .spacing(20) + .push(psbt::spend_header(tx)) + .push(psbt::spend_overview_view(tx, desc_info, key_aliases)) + .push(psbt::inputs_and_outputs_view( + &tx.coins, + &tx.psbt.unsigned_tx, + network, + Some(tx.change_indexes.clone()), + None, + )), + ) +} + +#[allow(clippy::too_many_arguments)] +pub fn create_spend_tx<'a>( + cache: &'a Cache, + balance_available: &'a Amount, + recipients: Vec>, + total_amount: Amount, + is_valid: bool, + duplicate: bool, + timelock: u16, + coins: &[(Coin, bool)], + amount_left: Option<&Amount>, + feerate: &form::Value, + error: Option<&Error>, +) -> Element<'a, Message> { + dashboard( + &Menu::CreateSpendTx, + cache, + error, + Column::new() + .push(h3("Send")) + .push( + Column::new() + .push(Column::with_children(recipients).spacing(10)) + .push( + Row::new() + .push_maybe(if duplicate { + Some( + Container::new( + text("Two recipient addresses are the same") + .style(color::RED), + ) + .padding(10), + ) + } else { + None + }) + .push(Space::with_width(Length::Fill)) + .push( + button::secondary(Some(icon::plus_icon()), "Add recipient") + .on_press(Message::CreateSpend( + CreateSpendMessage::AddRecipient, + )), + ), + ) + .spacing(20), + ) + .push( + Row::new() + .push( + Row::new() + .push(Container::new(p1_bold("Fee rate")).padding(10)) + .spacing(10) + .push( + form::Form::new("Feerate (sat/vbyte)", feerate, move |msg| { + Message::CreateSpend(CreateSpendMessage::FeerateEdited(msg)) + }) + .warning("Invalid feerate") + .size(20) + .padding(10), + ) + .width(Length::FillPortion(1)), + ) + .push(Space::with_width(Length::FillPortion(1))), + ) + .push( + Container::new( + Column::new() + .spacing(10) + .push( + Row::new() + .align_items(Alignment::Center) + .push(p1_bold("Coins selection").width(Length::Fill)) + .push(Container::new(if let Some(amount_left) = amount_left { + Row::new() + .spacing(5) + .push(amount_with_size(amount_left, P2_SIZE)) + .push(p2_regular("left to select").style(color::GREY_3)) + } else { + Row::new() + .push(text("Feerate needs to be set.").style(color::GREY_3)) + })) + .width(Length::Fill), + ) + .push( + Container::new(scrollable(coins.iter().enumerate().fold( + Column::new().spacing(10), + |col, (i, (coin, selected))| { + col.push(coin_list_view( + i, + coin, + timelock, + cache.blockheight as u32, + *selected, + )) + }, + ))) + .max_height(300), + ), + ) + .padding(20) + .style(theme::Card::Simple), + ) + .push( + Row::new() + .spacing(20) + .align_items(Alignment::Center) + .push(Space::with_width(Length::Fill)) + .push( + button::primary(None, "Clear") + .on_press(Message::Menu(Menu::CreateSpendTx)) + .width(Length::Units(100)), + ) + .push( + if is_valid + && total_amount < *balance_available + && Some(&Amount::from_sat(0)) == amount_left + { + button::primary(None, "Next") + .on_press(Message::CreateSpend(CreateSpendMessage::Generate)) + .width(Length::Units(100)) + } else { + button::primary(None, "Next").width(Length::Units(100)) + }, + ), + ) + .push(Space::with_height(Length::Units(20))) + .spacing(20), + ) +} + +pub fn recipient_view<'a>( + index: usize, + address: &form::Value, + amount: &form::Value, +) -> Element<'a, CreateSpendMessage> { + Container::new( + Column::new() + .spacing(10) + .push( + Row::new().push(Space::with_width(Length::Fill)).push( + Button::new(icon::cross_icon()) + .style(theme::Button::Transparent) + .on_press(CreateSpendMessage::DeleteRecipient(index)) + .width(Length::Shrink), + ), + ) + .push( + Row::new() + .align_items(Alignment::Start) + .spacing(10) + .push( + Container::new(p1_bold("Pay to")) + .align_x(alignment::Horizontal::Right) + .padding(10) + .width(Length::Units(80)), + ) + .push( + form::Form::new("Address", address, move |msg| { + CreateSpendMessage::RecipientEdited(index, "address", msg) + }) + .warning("Invalid address (maybe it is for another network?)") + .size(20) + .padding(10), + ), + ) + .push( + Row::new() + .align_items(Alignment::Start) + .spacing(10) + .push( + Container::new(p1_bold("Amount")) + .padding(10) + .align_x(alignment::Horizontal::Right) + .width(Length::Units(80)), + ) + .push( + form::Form::new("ex: 0.001", amount, move |msg| { + CreateSpendMessage::RecipientEdited(index, "amount", msg) + }) + .warning("Invalid amount. Must be > 0.00005000 BTC.") + .size(20) + .padding(10), + ) + .width(Length::Fill), + ), + ) + .padding(20) + .style(theme::Card::Simple) + .into() +} + +fn coin_list_view<'a>( + i: usize, + coin: &Coin, + timelock: u16, + blockheight: u32, + selected: bool, +) -> Element<'a, Message> { + Row::new() + .push( + Row::new() + .push(checkbox("", selected, move |_| { + Message::CreateSpend(CreateSpendMessage::SelectCoin(i)) + })) + .push(if coin.spend_info.is_some() { + badge::spent() + } else if coin.block_height.is_none() { + badge::unconfirmed() + } else { + let seq = remaining_sequence(coin, blockheight, timelock); + coins::coin_sequence_label(seq, timelock as u32) + }) + .spacing(10) + .align_items(Alignment::Center) + .width(Length::Fill), + ) + .push(amount(&coin.amount)) + // give some space for the scroll bar without using padding + .push(Space::with_width(Length::Units(0))) + .align_items(Alignment::Center) + .spacing(20) + .into() +} diff --git a/gui/src/app/view/spend/step.rs b/gui/src/app/view/spend/step.rs deleted file mode 100644 index 9fc5d720..00000000 --- a/gui/src/app/view/spend/step.rs +++ /dev/null @@ -1,249 +0,0 @@ -use iced::{ - alignment, - widget::{checkbox, scrollable, Space}, - Alignment, Length, -}; - -use liana::miniscript::bitcoin::Amount; - -use liana_ui::{ - color, - component::{amount::*, badge, button, form, text::*}, - icon, theme, - util::Collection, - widget::*, -}; - -use crate::{ - app::{ - cache::Cache, - error::Error, - menu::Menu, - view::{coins, dashboard, message::*}, - }, - daemon::model::{remaining_sequence, Coin}, -}; - -#[allow(clippy::too_many_arguments)] -pub fn create_spend_tx<'a>( - cache: &'a Cache, - balance_available: &'a Amount, - recipients: Vec>, - total_amount: Amount, - is_valid: bool, - duplicate: bool, - timelock: u16, - coins: &[(Coin, bool)], - amount_left: Option<&Amount>, - feerate: &form::Value, - error: Option<&Error>, -) -> Element<'a, Message> { - dashboard( - &Menu::CreateSpendTx, - cache, - error, - Column::new() - .push(h3("Send")) - .push( - Column::new() - .push(Column::with_children(recipients).spacing(10)) - .push( - Row::new() - .push_maybe(if duplicate { - Some( - Container::new( - text("Two recipient addresses are the same") - .style(color::RED), - ) - .padding(10), - ) - } else { - None - }) - .push(Space::with_width(Length::Fill)) - .push( - button::secondary(Some(icon::plus_icon()), "Add recipient") - .on_press(Message::CreateSpend( - CreateSpendMessage::AddRecipient, - )), - ), - ) - .spacing(20), - ) - .push( - Row::new() - .push( - Row::new() - .push(Container::new(p1_bold("Fee rate")).padding(10)) - .spacing(10) - .push( - form::Form::new("Feerate (sat/vbyte)", feerate, move |msg| { - Message::CreateSpend(CreateSpendMessage::FeerateEdited(msg)) - }) - .warning("Invalid feerate") - .size(20) - .padding(10), - ) - .width(Length::FillPortion(1)), - ) - .push(Space::with_width(Length::FillPortion(1))), - ) - .push( - Container::new( - Column::new() - .spacing(10) - .push( - Row::new() - .align_items(Alignment::Center) - .push(p1_bold("Coins selection").width(Length::Fill)) - .push(Container::new(if let Some(amount_left) = amount_left { - Row::new() - .spacing(5) - .push(amount_with_size(amount_left, P2_SIZE)) - .push(p2_regular("left to select").style(color::GREY_3)) - } else { - Row::new() - .push(text("Feerate needs to be set.").style(color::GREY_3)) - })) - .width(Length::Fill), - ) - .push( - Container::new(scrollable(coins.iter().enumerate().fold( - Column::new().spacing(10), - |col, (i, (coin, selected))| { - col.push(coin_list_view( - i, - coin, - timelock, - cache.blockheight as u32, - *selected, - )) - }, - ))) - .max_height(300), - ), - ) - .padding(20) - .style(theme::Card::Simple), - ) - .push( - Row::new() - .spacing(20) - .align_items(Alignment::Center) - .push(Space::with_width(Length::Fill)) - .push( - button::primary(None, "Clear") - .on_press(Message::Menu(Menu::CreateSpendTx)) - .width(Length::Units(100)), - ) - .push( - if is_valid - && total_amount < *balance_available - && Some(&Amount::from_sat(0)) == amount_left - { - button::primary(None, "Next") - .on_press(Message::CreateSpend(CreateSpendMessage::Generate)) - .width(Length::Units(100)) - } else { - button::primary(None, "Next").width(Length::Units(100)) - }, - ), - ) - .push(Space::with_height(Length::Units(20))) - .spacing(20), - ) -} - -pub fn recipient_view<'a>( - index: usize, - address: &form::Value, - amount: &form::Value, -) -> Element<'a, CreateSpendMessage> { - Container::new( - Column::new() - .spacing(10) - .push( - Row::new().push(Space::with_width(Length::Fill)).push( - Button::new(icon::cross_icon()) - .style(theme::Button::Transparent) - .on_press(CreateSpendMessage::DeleteRecipient(index)) - .width(Length::Shrink), - ), - ) - .push( - Row::new() - .align_items(Alignment::Start) - .spacing(10) - .push( - Container::new(p1_bold("Pay to")) - .align_x(alignment::Horizontal::Right) - .padding(10) - .width(Length::Units(80)), - ) - .push( - form::Form::new("Address", address, move |msg| { - CreateSpendMessage::RecipientEdited(index, "address", msg) - }) - .warning("Invalid address (maybe it is for another network?)") - .size(20) - .padding(10), - ), - ) - .push( - Row::new() - .align_items(Alignment::Start) - .spacing(10) - .push( - Container::new(p1_bold("Amount")) - .padding(10) - .align_x(alignment::Horizontal::Right) - .width(Length::Units(80)), - ) - .push( - form::Form::new("ex: 0.001", amount, move |msg| { - CreateSpendMessage::RecipientEdited(index, "amount", msg) - }) - .warning("Invalid amount. Must be > 0.00005000 BTC.") - .size(20) - .padding(10), - ) - .width(Length::Fill), - ), - ) - .padding(20) - .style(theme::Card::Simple) - .into() -} - -fn coin_list_view<'a>( - i: usize, - coin: &Coin, - timelock: u16, - blockheight: u32, - selected: bool, -) -> Element<'a, Message> { - Row::new() - .push( - Row::new() - .push(checkbox("", selected, move |_| { - Message::CreateSpend(CreateSpendMessage::SelectCoin(i)) - })) - .push(if coin.spend_info.is_some() { - badge::spent() - } else if coin.block_height.is_none() { - badge::unconfirmed() - } else { - let seq = remaining_sequence(coin, blockheight, timelock); - coins::coin_sequence_label(seq, timelock as u32) - }) - .spacing(10) - .align_items(Alignment::Center) - .width(Length::Fill), - ) - .push(amount(&coin.amount)) - // give some space for the scroll bar without using padding - .push(Space::with_width(Length::Units(0))) - .align_items(Alignment::Center) - .spacing(20) - .into() -} diff --git a/gui/src/app/view/transactions.rs b/gui/src/app/view/transactions.rs index 57a1a275..e2ac8f01 100644 --- a/gui/src/app/view/transactions.rs +++ b/gui/src/app/view/transactions.rs @@ -176,7 +176,7 @@ pub fn tx_view<'a>(cache: &Cache, tx: &'a HistoryTransaction) -> Element<'a, Mes ) .spacing(5), )) - .push(super::spend::detail::inputs_and_outputs_view( + .push(super::psbt::inputs_and_outputs_view( &tx.coins, &tx.tx, cache.network, From b4c7d1af505195bb4f1eb8a1864ab0c7c8e6e884 Mon Sep 17 00:00:00 2001 From: edouard Date: Tue, 25 Apr 2023 16:28:34 +0200 Subject: [PATCH 3/4] gui: refac psbt view --- gui/src/app/state/psbt.rs | 14 +- gui/src/app/state/spend/step.rs | 22 +- gui/src/app/view/mod.rs | 2 +- gui/src/app/view/psbt.rs | 681 +++++++++++++++----------------- gui/src/app/view/psbts.rs | 45 ++- gui/src/app/view/spend/mod.rs | 31 +- gui/src/daemon/model.rs | 4 + gui/ui/src/component/badge.rs | 13 + 8 files changed, 422 insertions(+), 390 deletions(-) diff --git a/gui/src/app/state/psbt.rs b/gui/src/app/state/psbt.rs index d6d2a832..cc1f1dae 100644 --- a/gui/src/app/state/psbt.rs +++ b/gui/src/app/state/psbt.rs @@ -30,7 +30,7 @@ use crate::{ hw::{list_hardware_wallets, HardwareWallet}, }; -trait Action { +pub trait Action { fn warning(&self) -> Option<&Error> { None } @@ -49,11 +49,11 @@ trait Action { } pub struct PsbtState { - wallet: Arc, - desc_policy: LianaPolicy, - tx: SpendTx, - saved: bool, - action: Option>, + pub wallet: Arc, + pub desc_policy: LianaPolicy, + pub tx: SpendTx, + pub saved: bool, + pub action: Option>, } impl PsbtState { @@ -129,7 +129,7 @@ impl PsbtState { } pub fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> { - let content = view::psbt::spend_view( + let content = view::psbt::psbt_view( cache, &self.tx, self.saved, diff --git a/gui/src/app/state/spend/step.rs b/gui/src/app/state/spend/step.rs index 206c9be9..1f58f805 100644 --- a/gui/src/app/state/spend/step.rs +++ b/gui/src/app/state/spend/step.rs @@ -10,7 +10,10 @@ use liana::{ }, }; -use liana_ui::{component::form, widget::Element}; +use liana_ui::{ + component::{form, modal}, + widget::Element, +}; use crate::{ app::{cache::Cache, error::Error, message::Message, state::psbt, view, wallet::Wallet}, @@ -427,6 +430,21 @@ impl Step for SaveSpend { } fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> { - self.spend.as_ref().unwrap().view(cache) + let spend = self.spend.as_ref().unwrap(); + let content = view::spend::spend_view( + cache, + &spend.tx, + spend.saved, + &spend.desc_policy, + &spend.wallet.keys_aliases, + cache.network, + ); + if let Some(action) = &spend.action { + modal::Modal::new(content, action.view()) + .on_blur(Some(view::Message::Spend(view::SpendTxMessage::Cancel))) + .into() + } else { + content + } } } diff --git a/gui/src/app/view/mod.rs b/gui/src/app/view/mod.rs index c7c3d787..5844948e 100644 --- a/gui/src/app/view/mod.rs +++ b/gui/src/app/view/mod.rs @@ -121,7 +121,7 @@ pub fn sidebar<'a>(menu: &Menu, cache: &'a Cache) -> Container<'a, Message> { .center_x(), ) .style(theme::Button::Menu(true)) - .on_press(Message::Reload) + .on_press(Message::Menu(Menu::PSBTs)) .width(iced::Length::Fill) } else { Button::new( diff --git a/gui/src/app/view/psbt.rs b/gui/src/app/view/psbt.rs index 9d2a7731..e1178cd0 100644 --- a/gui/src/app/view/psbt.rs +++ b/gui/src/app/view/psbt.rs @@ -16,11 +16,7 @@ use liana::{ use liana_ui::{ color, component::{ - amount::*, - badge, button, card, - collapse::Collapse, - form, hw, separation, - text::{text, Text}, + amount::*, badge, button, card, collapse::Collapse, form, hw, separation, text::*, }, icon, theme, util::Collection, @@ -31,27 +27,44 @@ use crate::{ app::{ cache::Cache, error::Error, + menu::Menu, view::{dashboard, hw::hw_list_view, message::*, warning::warn}, }, daemon::model::{Coin, SpendStatus, SpendTx}, hw::HardwareWallet, }; -pub fn spend_view<'a>( +pub fn psbt_view<'a>( cache: &'a Cache, tx: &'a SpendTx, - _saved: bool, + saved: bool, desc_info: &'a LianaPolicy, key_aliases: &'a HashMap, network: Network, ) -> Element<'a, Message> { dashboard( - &crate::app::menu::Menu::CreateSpendTx, - &cache, + &Menu::PSBTs, + cache, None, Column::new() - .align_items(Alignment::Center) .spacing(20) + .push( + Row::new() + .align_items(Alignment::Center) + .spacing(10) + .push(Container::new(h3("PSBT")).width(Length::Fill)) + .push_maybe(if !tx.sigs.recovery_paths().is_empty() { + Some(badge::recovery()) + } else { + None + }) + .push_maybe(match tx.status { + SpendStatus::Deprecated => Some(badge::deprecated()), + SpendStatus::Broadcast => Some(badge::unconfirmed()), + SpendStatus::Spent => Some(badge::spent()), + _ => None, + }), + ) .push(spend_header(tx)) .push(spend_overview_view(tx, desc_info, key_aliases)) .push(inputs_and_outputs_view( @@ -60,7 +73,25 @@ pub fn spend_view<'a>( network, Some(tx.change_indexes.clone()), None, - )), + )) + .push(if saved { + Row::new() + .push( + button::secondary(None, "Delete") + .width(Length::Units(200)) + .on_press(Message::Spend(SpendTxMessage::Delete)), + ) + .width(Length::Fill) + } else { + Row::new() + .push(Space::with_width(Length::Fill)) + .push( + button::secondary(None, "Save") + .width(Length::Units(150)) + .on_press(Message::Spend(SpendTxMessage::Save)), + ) + .width(Length::Fill) + }), ) } @@ -151,86 +182,21 @@ pub fn delete_action<'a>(warning: Option<&Error>, deleted: bool) -> Element<'a, } } -pub fn spend_modal<'a, T: Into>>( - saved: bool, - warning: Option<&Error>, - content: T, -) -> Element<'a, Message> { - Column::new() - .push(warn(warning)) - .push( - Container::new( - Row::new() - .push(if saved { - Column::new() - .push( - button::alert(Some(icon::trash_icon()), "Delete") - .on_press(Message::Spend(SpendTxMessage::Delete)), - ) - .width(Length::Fill) - } else { - Column::new() - .push( - button::transparent(None, "< Previous").on_press(Message::Previous), - ) - .width(Length::Fill) - }) - .align_items(iced::Alignment::Center) - .push(if saved { - button::primary(Some(icon::cross_icon()), "Close").on_press(Message::Close) - } else { - button::primary(Some(icon::cross_icon()), "Close") - .on_press(Message::Spend(SpendTxMessage::Save)) - }), - ) - .padding(10) - .style(theme::Container::Background), - ) - .push( - Container::new(scrollable( - Container::new(Container::new(content).max_width(800)) - .width(Length::Fill) - .center_x(), - )) - .height(Length::Fill) - .style(theme::Container::Background), - ) - .width(Length::Fill) - .height(Length::Fill) - .into() -} - pub fn spend_header<'a>(tx: &SpendTx) -> Element<'a, Message> { Column::new() .spacing(20) - .align_items(Alignment::Center) - .push( - Row::new() - .push(badge::Badge::new(icon::send_icon()).style(theme::Badge::Standard)) - .push(if !tx.sigs.recovery_paths().is_empty() { - text("Recovery").bold() - } else if tx.spend_amount == Amount::from_sat(0) { - text("Self send").bold() - } else { - text("Spend").bold() - }) - .spacing(5) - .align_items(Alignment::Center), - ) - .push_maybe(match tx.status { - SpendStatus::Deprecated => Some(badge::deprecated()), - SpendStatus::Broadcast => Some(badge::unconfirmed()), - SpendStatus::Spent => Some(badge::spent()), - _ => None, - }) .push( Column::new() - .align_items(Alignment::Center) - .push(amount_with_size(&tx.spend_amount, 50)) + .push(if tx.is_self_send() { + Container::new(h1("Self send")) + } else { + Container::new(amount_with_size(&tx.spend_amount, H1_SIZE)) + }) .push( Row::new() - .push(text("Miner fee: ")) - .push(amount(&tx.fee_amount)), + .align_items(Alignment::Center) + .push(h3("Miner fee: ").style(color::GREY_3)) + .push(amount_with_size(&tx.fee_amount, H3_SIZE)), ), ) .into() @@ -241,48 +207,84 @@ pub fn spend_overview_view<'a>( desc_info: &'a LianaPolicy, key_aliases: &'a HashMap, ) -> Element<'a, Message> { - Container::new( - Column::new() - .push( + Column::new() + .spacing(20) + .push( + Container::new( Column::new() - .padding(15) - .spacing(10) .push( - Row::new() - .align_items(Alignment::Center) - .push(text("PSBT:").bold().width(Length::Fill)) + Column::new() + .padding(15) + .spacing(10) .push( Row::new() - .spacing(5) + .align_items(Alignment::Center) + .push(text("PSBT").bold().width(Length::Fill)) .push( - button::secondary(Some(icon::clipboard_icon()), "Copy") - .on_press(Message::Clipboard(tx.psbt.to_string())), + Row::new() + .spacing(5) + .push( + button::secondary( + Some(icon::clipboard_icon()), + "Copy", + ) + .on_press(Message::Clipboard(tx.psbt.to_string())), + ) + .push( + button::secondary( + Some(icon::import_icon()), + "Update", + ) + .on_press(Message::Spend(SpendTxMessage::EditPsbt)), + ), + ) + .align_items(Alignment::Center), + ) + .push( + Row::new() + .push(p1_bold("Tx ID").width(Length::Fill)) + .push( + p2_regular(tx.psbt.unsigned_tx.txid().to_string()) + .style(color::GREY_3), ) .push( - button::secondary(Some(icon::import_icon()), "Update") - .on_press(Message::Spend(SpendTxMessage::EditPsbt)), - ), - ) - .align_items(Alignment::Center), + Button::new(icon::clipboard_icon().style(color::GREY_3)) + .on_press(Message::Clipboard( + tx.psbt.unsigned_tx.txid().to_string(), + )) + .style(theme::Button::TransparentBorder), + ) + .align_items(Alignment::Center), + ), ) - .push( - Row::new() - .push(text("Tx ID:").bold().width(Length::Fill)) - .push(text(tx.psbt.unsigned_tx.txid().to_string()).small()) - .push( - Button::new(icon::clipboard_icon()) - .on_press(Message::Clipboard( - tx.psbt.unsigned_tx.txid().to_string(), - )) - .style(theme::Button::TransparentBorder), - ) - .align_items(Alignment::Center), - ), + .push(signatures(tx, desc_info, key_aliases)), ) - .push(signatures(tx, desc_info, key_aliases)), - ) - .style(theme::Container::Card(theme::Card::Simple)) - .into() + .style(theme::Container::Card(theme::Card::Simple)), + ) + .push_maybe(if tx.status == SpendStatus::Pending { + Some( + Row::new() + .push(Space::with_width(Length::Fill)) + .push_maybe(if tx.path_ready().is_none() { + Some( + button::primary(None, "Sign") + .on_press(Message::Spend(SpendTxMessage::Sign)) + .width(Length::Units(150)), + ) + } else { + Some( + button::primary(None, "Broadcast") + .on_press(Message::Spend(SpendTxMessage::Broadcast)) + .width(Length::Units(150)), + ) + }) + .align_items(Alignment::Center) + .spacing(20), + ) + } else { + None + }) + .into() } pub fn signatures<'a>( @@ -298,9 +300,11 @@ pub fn signatures<'a>( Row::new() .spacing(5) .align_items(Alignment::Center) + .spacing(10) + .push(p1_bold("Status")) .push(icon::circle_check_icon().style(color::GREEN)) .push(text("Ready").bold().style(color::GREEN)) - .push(text(", signed by")) + .push(text(" signed by")) .push( sigs.signed_pubkeys .keys() @@ -309,7 +313,7 @@ pub fn signatures<'a>( Container::new( tooltip::Tooltip::new( Container::new(text(alias)) - .padding(3) + .padding(10) .style(theme::Container::Pill(theme::Pill::Simple)), value.0.to_string(), tooltip::Position::Bottom, @@ -318,7 +322,7 @@ pub fn signatures<'a>( ) } else { Container::new(text(value.0.to_string())) - .padding(3) + .padding(10) .style(theme::Container::Pill(theme::Pill::Simple)) }) }), @@ -332,11 +336,13 @@ pub fn signatures<'a>( Button::new( Row::new() .align_items(Alignment::Center) + .spacing(20) + .push(p1_bold("Status")) .push(Row::new() .spacing(5) .align_items(Alignment::Center) - .push(icon::circle_cross_icon()) - .push(text("Not ready").bold()) + .push(icon::circle_cross_icon().style(color::RED)) + .push(text("Not ready").style(color::RED)) .width(Length::Fill) ) .push(icon::collapse_icon()), @@ -349,12 +355,14 @@ pub fn signatures<'a>( Button::new( Row::new() .align_items(Alignment::Center) + .spacing(20) + .push(p1_bold("Status")) .push( Row::new() .spacing(5) .align_items(Alignment::Center) - .push(icon::circle_cross_icon()) - .push(text("Not ready").bold()) + .push(icon::circle_cross_icon().style(color::RED)) + .push(text("Not ready").style(color::RED)) .width(Length::Fill) ) .push(icon::collapsed_icon()), @@ -365,7 +373,6 @@ pub fn signatures<'a>( }, move || { Into::>::into( - Column::new().push(separation().width(Length::Fill)).push( Column::new() .padding(15) .spacing(10) @@ -383,38 +390,9 @@ pub fn signatures<'a>( let keys = &desc_info.recovery_paths()[seq]; col.push(path_view(keys, path, keys_aliases)) })), - ), ) }, ))}) - .push_maybe(if tx.status == SpendStatus::Pending { - Some( - Column::new().push(separation().width(Length::Fill)).push( - Container::new( - Row::new() - .push(Space::with_width(Length::Fill)) - .push_maybe(if tx.path_ready().is_none() { - Some( - button::primary(None, "Sign") - .on_press(Message::Spend(SpendTxMessage::Sign)) - .width(Length::Units(150)), - ) - } else { - Some( - button::primary(None, "Broadcast") - .on_press(Message::Spend(SpendTxMessage::Broadcast)) - .width(Length::Units(150)), - ) - }) - .align_items(Alignment::Center) - .spacing(20), - ) - .padding(15), - ), - ) - } else { - None - }) .into() } @@ -434,22 +412,29 @@ pub fn path_view<'a>( scrollable( Row::new() .align_items(Alignment::Center) - .push(if sigs.sigs_count >= sigs.threshold { - icon::circle_check_icon().style(color::GREEN) - } else { - icon::circle_cross_icon() - }) - .push(text(format!(" {}", missing_signatures)).bold()) - .push(text(format!( - " more signature{}", - if missing_signatures > 1 { - "s from " - } else if missing_signatures == 0 { - "" - } else { - " from " - } - ))) + .push( + Row::new() + .push(if sigs.sigs_count >= sigs.threshold { + icon::circle_check_icon().style(color::GREEN) + } else { + icon::circle_cross_icon().style(color::GREY_3) + }) + .push(Space::with_width(Length::Units(20))), + ) + .push( + p1_regular(format!( + "{} more signature{}", + missing_signatures, + if missing_signatures > 1 { + "s from " + } else if missing_signatures == 0 { + "" + } else { + " from " + } + )) + .style(color::GREY_3), + ) .push_maybe(if keys.is_empty() { None } else { @@ -459,7 +444,7 @@ pub fn path_view<'a>( Container::new( tooltip::Tooltip::new( Container::new(text(alias)) - .padding(3) + .padding(10) .style(theme::Container::Pill(theme::Pill::Simple)), value.0.to_string(), tooltip::Position::Bottom, @@ -468,7 +453,7 @@ pub fn path_view<'a>( ) } else { Container::new(text(value.0.to_string())) - .padding(3) + .padding(10) .style(theme::Container::Pill(theme::Pill::Simple)) }) } else { @@ -479,7 +464,7 @@ pub fn path_view<'a>( .push_maybe(if sigs.signed_pubkeys.is_empty() { None } else { - Some(text(", already signed by ")) + Some(p1_regular(", already signed by ").style(color::GREY_3)) }) .push( sigs.signed_pubkeys @@ -489,7 +474,7 @@ pub fn path_view<'a>( Container::new( tooltip::Tooltip::new( Container::new(text(alias)) - .padding(3) + .padding(10) .style(theme::Container::Pill(theme::Pill::Simple)), value.0.to_string(), tooltip::Position::Bottom, @@ -516,198 +501,184 @@ pub fn inputs_and_outputs_view<'a>( receive_indexes: Option>, ) -> Element<'a, Message> { Column::new() - .push( - Column::new() - .spacing(10) - .push_maybe(if !coins.is_empty() { - Some( - Container::new(Collapse::new( - move || { - Button::new( - Row::new() - .align_items(Alignment::Center) - .push( - text(format!( - "{} spent coin{}", - coins.len(), - if coins.len() == 1 { "" } else { "s" } - )) - .bold() - .width(Length::Fill), - ) - .push(icon::collapse_icon()), + .spacing(20) + .push_maybe(if !coins.is_empty() { + Some( + Container::new(Collapse::new( + move || { + Button::new( + Row::new() + .align_items(Alignment::Center) + .push( + h4_bold(format!( + "{} spent coin{}", + coins.len(), + if coins.len() == 1 { "" } else { "s" } + )) + .width(Length::Fill), ) - .padding(15) - .width(Length::Fill) - .style(theme::Button::TransparentBorder) - }, - move || { - Button::new( - Row::new() - .align_items(Alignment::Center) - .push( - text(format!( - "{} spent coin{}", - coins.len(), - if coins.len() == 1 { "" } else { "s" } - )) - .bold() - .width(Length::Fill), - ) - .push(icon::collapsed_icon()), + .push(icon::collapse_icon()), + ) + .padding(20) + .width(Length::Fill) + .style(theme::Button::TransparentBorder) + }, + move || { + Button::new( + Row::new() + .align_items(Alignment::Center) + .push( + h4_bold(format!( + "{} spent coin{}", + coins.len(), + if coins.len() == 1 { "" } else { "s" } + )) + .width(Length::Fill), ) - .padding(15) - .width(Length::Fill) - .style(theme::Button::TransparentBorder) - }, - move || { - coins - .iter() - .fold(Column::new(), |col: Column<'a, Message>, coin| { - col.push( - Row::new() - .padding(15) - .align_items(Alignment::Center) - .width(Length::Fill) - .push( - Row::new() - .width(Length::Fill) - .align_items(Alignment::Center) - .push( - text(coin.outpoint.to_string()) - .small() - ) - .push( - Button::new(icon::clipboard_icon()) - .on_press(Message::Clipboard( - coin.outpoint.to_string(), - )) - .style( - theme::Button::TransparentBorder, - ), - ), - ) - .push(amount(&coin.amount)), - ) - }) - .into() - }, - )) - .style(theme::Container::Card(theme::Card::Simple)), - ) - } else { - None - }) - .push( - Container::new(Collapse::new( - move || { - Button::new( - Row::new() - .align_items(Alignment::Center) - .push( - text(format!( - "{} recipient{}", - tx.output.len(), - if tx.output.len() == 1 { "" } else { "s" } - )) - .bold() - .width(Length::Fill), - ) - .push(icon::collapse_icon()), - ) - .padding(15) - .width(Length::Fill) - .style(theme::Button::TransparentBorder) - }, - move || { - Button::new( - Row::new() - .align_items(Alignment::Center) - .push( - text(format!( - "{} recipient{}", - tx.output.len(), - if tx.output.len() == 1 { "" } else { "s" } - )) - .bold() - .width(Length::Fill), - ) - .push(icon::collapsed_icon()), - ) - .padding(15) - .width(Length::Fill) - .style(theme::Button::TransparentBorder) - }, - move || { - tx.output - .iter() - .enumerate() - .fold(Column::new(), |col: Column<'a, Message>, (i, output)| { - let addr = Address::from_script(&output.script_pubkey, network).unwrap(); + .push(icon::collapsed_icon()), + ) + .padding(20) + .width(Length::Fill) + .style(theme::Button::TransparentBorder) + }, + move || { + coins + .iter() + .fold( + Column::new().padding(20), + |col: Column<'a, Message>, coin| { col.push( - Column::new() - .padding(15) + Row::new() + .align_items(Alignment::Center) .width(Length::Fill) - .spacing(10) .push( Row::new() .width(Length::Fill) + .align_items(Alignment::Center) + .push(p2_regular(coin.outpoint.to_string()).style(color::GREY_3)) .push( - Row::new() - .align_items(Alignment::Center) - .width(Length::Fill) - .push(text(addr.to_string()).small()) - .push( - Button::new(icon::clipboard_icon()) - .on_press(Message::Clipboard( - addr.to_string(), - )) - .style( - theme::Button::TransparentBorder, - ), - ), - ) - .push( - amount(&Amount::from_sat(output.value)) + Button::new(icon::clipboard_icon().style(color::GREY_3)) + .on_press(Message::Clipboard( + coin.outpoint.to_string(), + )) + .style( + theme::Button::TransparentBorder, + ), ), ) - .push_maybe( - if let Some(indexes) = change_indexes.as_ref() { - if indexes.contains(&i) { - Some( - Container::new(text("Change")) - .padding(5) - .style(theme::Container::Pill(theme::Pill::Success)), - ) - } else { - None - } - } else { - None - }, - ) - .push_maybe( - if let Some(indexes) = receive_indexes.as_ref() { - if indexes.contains(&i) { - Some( - Container::new(text("Deposit")) - .padding(5) - .style(theme::Container::Pill(theme::Pill::Success)), - ) - } else { - None - } - } else { - None - }, - ), + .push(amount(&coin.amount)), ) - }) - .into() - }, - )) - .style(theme::Container::Card(theme::Card::Simple)), - ), + }, + ) + .into() + }, + )) + .style(theme::Container::Card(theme::Card::Simple)), + ) + } else { + None + }) + .push( + Container::new(Collapse::new( + move || { + Button::new( + Row::new() + .align_items(Alignment::Center) + .push( + h4_bold(format!( + "{} recipient{}", + tx.output.len(), + if tx.output.len() == 1 { "" } else { "s" } + )) + .width(Length::Fill), + ) + .push(icon::collapse_icon()), + ) + .padding(20) + .width(Length::Fill) + .style(theme::Button::TransparentBorder) + }, + move || { + Button::new( + Row::new() + .align_items(Alignment::Center) + .push( + h4_bold(format!( + "{} recipient{}", + tx.output.len(), + if tx.output.len() == 1 { "" } else { "s" } + )) + .width(Length::Fill), + ) + .push(icon::collapsed_icon()), + ) + .padding(20) + .width(Length::Fill) + .style(theme::Button::TransparentBorder) + }, + move || { + tx.output + .iter() + .enumerate() + .fold( + Column::new().padding(20), + |col: Column<'a, Message>, (i, output)| { + let addr = + Address::from_script(&output.script_pubkey, network).unwrap(); + col.push( + Column::new() + .width(Length::Fill) + .spacing(5) + .push( + Row::new() + .align_items(Alignment::Center) + .width(Length::Fill) + .push( + Row::new() + .align_items(Alignment::Center) + .width(Length::Fill) + .push(p2_regular(addr.to_string()).style(color::GREY_3)) + .push( + Button::new(icon::clipboard_icon().style(color::GREY_3)) + .on_press(Message::Clipboard( + addr.to_string(), + )) + .style( + theme::Button::TransparentBorder, + ), + ), + ) + .push(amount(&Amount::from_sat(output.value))), + ) + .push_maybe(if let Some(indexes) = change_indexes.as_ref() { + if indexes.contains(&i) { + Some(Container::new(text("Change")).padding(5).style( + theme::Container::Pill(theme::Pill::Success), + )) + } else { + None + } + } else { + None + }) + .push_maybe(if let Some(indexes) = receive_indexes.as_ref() { + if indexes.contains(&i) { + Some(Container::new(text("Deposit")).padding(5).style( + theme::Container::Pill(theme::Pill::Success), + )) + } else { + None + } + } else { + None + }), + ) + }, + ) + .into() + }, + )) + .style(theme::Container::Card(theme::Card::Simple)), ) .into() } diff --git a/gui/src/app/view/psbts.rs b/gui/src/app/view/psbts.rs index 214a0739..ef6b893b 100644 --- a/gui/src/app/view/psbts.rs +++ b/gui/src/app/view/psbts.rs @@ -98,29 +98,27 @@ fn spend_tx_list_view<'a>(i: usize, tx: &SpendTx) -> Element<'a, Message> { Row::new() .push(badge::spend()) .push(if !tx.sigs.recovery_paths().is_empty() { - Row::new().push( - Container::new(p2_regular(" Recovery ")) - .padding(10) - .style(theme::Container::Pill(theme::Pill::Simple)), - ) + badge::recovery() } else { let sigs = tx.sigs.primary_path(); - Row::new() - .spacing(5) - .align_items(Alignment::Center) - .push( - p2_regular(format!( - "{}/{}", - if sigs.sigs_count <= sigs.threshold { - sigs.sigs_count - } else { + Container::new( + Row::new() + .spacing(5) + .align_items(Alignment::Center) + .push( + p2_regular(format!( + "{}/{}", + if sigs.sigs_count <= sigs.threshold { + sigs.sigs_count + } else { + sigs.threshold + }, sigs.threshold - }, - sigs.threshold - )) - .style(color::GREY_3), - ) - .push(icon::key_icon().style(color::GREY_3)) + )) + .style(color::GREY_3), + ) + .push(icon::key_icon().style(color::GREY_3)), + ) }) .spacing(10) .align_items(Alignment::Center) @@ -134,7 +132,12 @@ fn spend_tx_list_view<'a>(i: usize, tx: &SpendTx) -> Element<'a, Message> { }) .push( Column::new() - .push(amount(&tx.spend_amount)) + .align_items(Alignment::End) + .push(if tx.is_self_send() { + Container::new(amount(&tx.spend_amount)) + } else { + Container::new(p1_regular("Self send")) + }) .push(amount_with_size(&tx.fee_amount, P2_SIZE)) .width(Length::Shrink), ) diff --git a/gui/src/app/view/spend/mod.rs b/gui/src/app/view/spend/mod.rs index 8dd63617..5cf664fe 100644 --- a/gui/src/app/view/spend/mod.rs +++ b/gui/src/app/view/spend/mod.rs @@ -32,18 +32,18 @@ use crate::{ pub fn spend_view<'a>( cache: &'a Cache, tx: &'a SpendTx, - _saved: bool, + saved: bool, desc_info: &'a LianaPolicy, key_aliases: &'a HashMap, network: Network, ) -> Element<'a, Message> { dashboard( &Menu::CreateSpendTx, - &cache, + cache, None, Column::new() - .align_items(Alignment::Center) .spacing(20) + .push(Container::new(h3("Send")).width(Length::Fill)) .push(psbt::spend_header(tx)) .push(psbt::spend_overview_view(tx, desc_info, key_aliases)) .push(psbt::inputs_and_outputs_view( @@ -52,7 +52,30 @@ pub fn spend_view<'a>( network, Some(tx.change_indexes.clone()), None, - )), + )) + .push(if saved { + Row::new() + .push( + button::secondary(None, "Delete") + .width(Length::Units(200)) + .on_press(Message::Spend(SpendTxMessage::Delete)), + ) + .width(Length::Fill) + } else { + Row::new() + .push( + button::secondary(None, "< Previous") + .width(Length::Units(150)) + .on_press(Message::Previous), + ) + .push(Space::with_width(Length::Fill)) + .push( + button::secondary(None, "Save") + .width(Length::Units(150)) + .on_press(Message::Spend(SpendTxMessage::Save)), + ) + .width(Length::Fill) + }), ) } diff --git a/gui/src/daemon/model.rs b/gui/src/daemon/model.rs index 2737a4b0..178c02e0 100644 --- a/gui/src/daemon/model.rs +++ b/gui/src/daemon/model.rs @@ -121,6 +121,10 @@ impl SpendTx { signers } + + pub fn is_self_send(&self) -> bool { + !self.coins.is_empty() && self.spend_amount == Amount::from_sat(0) + } } #[derive(Debug, Clone)] diff --git a/gui/ui/src/component/badge.rs b/gui/ui/src/component/badge.rs index 41e09a53..b0b271e2 100644 --- a/gui/ui/src/component/badge.rs +++ b/gui/ui/src/component/badge.rs @@ -65,6 +65,19 @@ pub fn coin() -> Container<'static, T> { .center_y() } +pub fn recovery<'a, T: 'a>() -> Container<'a, T> { + Container::new( + tooltip::Tooltip::new( + Container::new(text::p2_regular(" Recovery ")) + .padding(10) + .style(theme::Container::Pill(theme::Pill::Simple)), + "This transaction is using a recovery path", + tooltip::Position::Top, + ) + .style(theme::Container::Card(theme::Card::Simple)), + ) +} + pub fn unconfirmed<'a, T: 'a>() -> Container<'a, T> { Container::new( tooltip::Tooltip::new( From e4da8e9be7f9a5d4d08ce5996e3b789f9dc69143 Mon Sep 17 00:00:00 2001 From: edouard Date: Tue, 25 Apr 2023 21:47:41 +0200 Subject: [PATCH 4/4] gui: swap transactions and psbts menus close #458 --- gui/src/app/view/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gui/src/app/view/mod.rs b/gui/src/app/view/mod.rs index 5844948e..36450c41 100644 --- a/gui/src/app/view/mod.rs +++ b/gui/src/app/view/mod.rs @@ -214,8 +214,8 @@ pub fn sidebar<'a>(menu: &Menu, cache: &'a Cache) -> Container<'a, Message> { .push(spend_button) .push(receive_button) .push(coins_button) - .push(psbt_button) .push(transactions_button) + .push(psbt_button) .height(Length::Fill), ) .push(