Merge #75: Add spend panel
6f75515f9b572d6fa77facdba9ddd2cd1bf94a64 Add coins and recipients view (edouard) a043f024e2e6fd2355b8bff3e27c7e8e58886c92 fix clippy errors (edouard) 063786fe5485e4f5a857ed9c45f3fbec3fc5f69a Create spend transaction (edouard) 7fda64f4adae04e2f95439975e6e82a66c0a6035 Add select inputs step (edouard) 0b2f64279b92bf0502f4edc1d9bee3c231e933e8 Add choose feerate step (edouard) 5b9414260b34c37e9cddc7d79eb0a16d8d42767e Add choose recipient step (edouard) e1209d2cff01a11053263c2e4934e80c088a83bd Add spend panel (edouard) 95aa8a152993a43dc2451c45efcfe73d68f4c45b Add spend txs to app cache (edouard) Pull request description: based on #71 ACKs for top commit: edouardparis: Self-ACK 6f75515f9b572d6fa77facdba9ddd2cd1bf94a64 Tree-SHA512: 7edeb66b21af61f610d492576aac56b2dc1bfe1eb12a665787e5db03f54b55970c607d6ff29d6227360c7544bae4168f8d22ade648a0e13bc2f9050af0d95d06
This commit is contained in:
commit
76bd319d88
1
gui/Cargo.lock
generated
1
gui/Cargo.lock
generated
@ -1619,6 +1619,7 @@ version = "0.0.1"
|
||||
dependencies = [
|
||||
"async-hwi",
|
||||
"backtrace",
|
||||
"base64",
|
||||
"chrono",
|
||||
"dirs",
|
||||
"fern",
|
||||
|
||||
@ -17,6 +17,7 @@ path = "src/main.rs"
|
||||
async-hwi = { git = "https://github.com/revault/async-hwi", branch = "master" }
|
||||
minisafe = { git = "https://github.com/revault/minisafe", branch = "master", default-features = false }
|
||||
backtrace = "0.3"
|
||||
base64 = "0.13"
|
||||
|
||||
iced = { version = "0.4", default-features= false, features = ["tokio", "wgpu", "svg", "qr_code", "pure"] }
|
||||
iced_native = "0.5"
|
||||
|
||||
@ -1,7 +1,20 @@
|
||||
use crate::daemon::model::Coin;
|
||||
use crate::daemon::model::{Coin, SpendTx};
|
||||
use minisafe::miniscript::bitcoin::Network;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Cache {
|
||||
pub network: Network,
|
||||
pub blockheight: i32,
|
||||
pub coins: Vec<Coin>,
|
||||
pub spend_txs: Vec<SpendTx>,
|
||||
}
|
||||
|
||||
impl std::default::Default for Cache {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
network: Network::Bitcoin,
|
||||
blockheight: 0,
|
||||
coins: Vec::new(),
|
||||
spend_txs: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ pub enum Error {
|
||||
Config(String),
|
||||
Daemon(DaemonError),
|
||||
Unexpected(String),
|
||||
HardwareWallet(async_hwi::Error),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Error {
|
||||
@ -35,6 +36,7 @@ impl std::fmt::Display for Error {
|
||||
}
|
||||
},
|
||||
Self::Unexpected(e) => write!(f, "Unexpected error: {}", e),
|
||||
Self::HardwareWallet(e) => write!(f, "{}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -50,3 +52,9 @@ impl From<DaemonError> for Error {
|
||||
Error::Daemon(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<async_hwi::Error> for Error {
|
||||
fn from(error: async_hwi::Error) -> Self {
|
||||
Error::HardwareWallet(error)
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
pub enum Menu {
|
||||
Home,
|
||||
Receive,
|
||||
Spend,
|
||||
Settings,
|
||||
Coins,
|
||||
CreateSpendTx,
|
||||
}
|
||||
|
||||
@ -1,8 +1,15 @@
|
||||
use minisafe::{config::Config as DaemonConfig, miniscript::bitcoin::Address};
|
||||
use minisafe::{
|
||||
config::Config as DaemonConfig,
|
||||
miniscript::bitcoin::{
|
||||
util::{bip32::Fingerprint, psbt::Psbt},
|
||||
Address,
|
||||
},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
app::{error::Error, view},
|
||||
daemon::model::*,
|
||||
hw::HardwareWallet,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
@ -15,4 +22,9 @@ pub enum Message {
|
||||
BlockHeight(Result<i32, Error>),
|
||||
ReceiveAddress(Result<Address, Error>),
|
||||
Coins(Result<Vec<Coin>, Error>),
|
||||
SpendTxs(Result<Vec<SpendTx>, Error>),
|
||||
Psbt(Result<Psbt, Error>),
|
||||
Signed(Result<(Psbt, Fingerprint), Error>),
|
||||
Updated(Result<(), Error>),
|
||||
ConnectedHardwareWallets(Vec<HardwareWallet>),
|
||||
}
|
||||
|
||||
@ -21,7 +21,7 @@ pub use minisafe::config::Config as DaemonConfig;
|
||||
pub use config::Config;
|
||||
pub use message::Message;
|
||||
|
||||
use state::{CoinsPanel, Home, ReceivePanel, State};
|
||||
use state::{CoinsPanel, CreateSpendPanel, Home, ReceivePanel, SpendPanel, State};
|
||||
|
||||
use crate::{
|
||||
app::{cache::Cache, error::Error, menu::Menu},
|
||||
@ -65,6 +65,10 @@ impl App {
|
||||
menu::Menu::Home => Home::new(&self.cache.coins).into(),
|
||||
menu::Menu::Coins => CoinsPanel::new(&self.cache.coins).into(),
|
||||
menu::Menu::Receive => ReceivePanel::default().into(),
|
||||
menu::Menu::Spend => SpendPanel::new(self.config.clone(), &self.cache.spend_txs).into(),
|
||||
menu::Menu::CreateSpendTx => {
|
||||
CreateSpendPanel::new(self.config.clone(), &self.cache.coins).into()
|
||||
}
|
||||
};
|
||||
self.state.load(self.daemon.clone())
|
||||
}
|
||||
@ -102,6 +106,9 @@ impl App {
|
||||
Message::Coins(Ok(coins)) => {
|
||||
self.cache.coins = coins.clone();
|
||||
}
|
||||
Message::SpendTxs(Ok(txs)) => {
|
||||
self.cache.spend_txs = txs.clone();
|
||||
}
|
||||
Message::BlockHeight(Ok(blockheight)) => {
|
||||
self.cache.blockheight = *blockheight;
|
||||
}
|
||||
|
||||
@ -25,9 +25,10 @@ impl CoinsPanel {
|
||||
}
|
||||
|
||||
impl State for CoinsPanel {
|
||||
fn view<'a>(&'a self, _cache: &'a Cache) -> Element<'a, view::Message> {
|
||||
fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> {
|
||||
view::dashboard(
|
||||
&Menu::Coins,
|
||||
cache,
|
||||
self.warning.as_ref(),
|
||||
view::coins::coins_view(&self.coins),
|
||||
)
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
mod coins;
|
||||
mod settings;
|
||||
mod spend;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@ -12,6 +13,7 @@ use crate::daemon::{model::Coin, Daemon};
|
||||
|
||||
pub use coins::CoinsPanel;
|
||||
pub use settings::SettingsState;
|
||||
pub use spend::{CreateSpendPanel, SpendPanel};
|
||||
|
||||
pub trait State {
|
||||
fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message>;
|
||||
@ -36,14 +38,30 @@ pub struct Home {
|
||||
impl Home {
|
||||
pub fn new(coins: &[Coin]) -> Self {
|
||||
Self {
|
||||
balance: Amount::from_sat(coins.iter().map(|coin| coin.amount.to_sat()).sum()),
|
||||
balance: Amount::from_sat(
|
||||
coins
|
||||
.iter()
|
||||
.map(|coin| {
|
||||
if coin.spend_info.is_none() {
|
||||
coin.amount.to_sat()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
})
|
||||
.sum(),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl State for Home {
|
||||
fn view<'a>(&'a self, _cache: &'a Cache) -> Element<'a, view::Message> {
|
||||
view::dashboard(&Menu::Home, None, view::home::home_view(&self.balance))
|
||||
fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> {
|
||||
view::dashboard(
|
||||
&Menu::Home,
|
||||
cache,
|
||||
None,
|
||||
view::home::home_view(&self.balance),
|
||||
)
|
||||
}
|
||||
|
||||
fn update(
|
||||
@ -83,15 +101,16 @@ pub struct ReceivePanel {
|
||||
}
|
||||
|
||||
impl State for ReceivePanel {
|
||||
fn view<'a>(&'a self, _cache: &'a Cache) -> Element<'a, view::Message> {
|
||||
fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> {
|
||||
if let Some(address) = &self.address {
|
||||
view::dashboard(
|
||||
&Menu::Receive,
|
||||
cache,
|
||||
self.warning.as_ref(),
|
||||
view::receive::receive(address, self.qr_code.as_ref().unwrap()),
|
||||
)
|
||||
} else {
|
||||
view::dashboard(&Menu::Receive, self.warning.as_ref(), column())
|
||||
view::dashboard(&Menu::Receive, cache, self.warning.as_ref(), column())
|
||||
}
|
||||
}
|
||||
fn update(
|
||||
@ -133,6 +152,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::*;
|
||||
|
||||
@ -103,6 +103,7 @@ impl State for SettingsState {
|
||||
fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> {
|
||||
let can_edit = self.current.is_none() && !self.daemon_is_external;
|
||||
view::settings::list(
|
||||
cache,
|
||||
self.warning.as_ref(),
|
||||
self.settings
|
||||
.iter()
|
||||
|
||||
369
gui/src/app/state/spend/detail.rs
Normal file
369
gui/src/app/state/spend/detail.rs
Normal file
@ -0,0 +1,369 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use iced::pure::Element;
|
||||
use iced::Command;
|
||||
use minisafe::miniscript::bitcoin::util::{bip32::Fingerprint, psbt::Psbt};
|
||||
|
||||
use crate::{
|
||||
app::{
|
||||
cache::Cache, config::Config, error::Error, message::Message, view, view::spend::detail,
|
||||
},
|
||||
daemon::{
|
||||
model::{SpendStatus, SpendTx},
|
||||
Daemon,
|
||||
},
|
||||
hw::{list_hardware_wallets, HardwareWallet},
|
||||
};
|
||||
|
||||
trait Action {
|
||||
fn warning(&self) -> Option<&Error> {
|
||||
None
|
||||
}
|
||||
fn updated(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn load(&self, _daemon: Arc<dyn Daemon + Sync + Send>) -> Command<Message> {
|
||||
Command::none()
|
||||
}
|
||||
fn update(
|
||||
&mut self,
|
||||
_daemon: Arc<dyn Daemon + Sync + Send>,
|
||||
_cache: &Cache,
|
||||
_message: Message,
|
||||
_tx: &mut SpendTx,
|
||||
) -> Command<Message> {
|
||||
Command::none()
|
||||
}
|
||||
fn view(&self) -> Element<view::Message>;
|
||||
}
|
||||
|
||||
pub struct SpendTxState {
|
||||
config: Config,
|
||||
tx: SpendTx,
|
||||
saved: bool,
|
||||
action: Box<dyn Action>,
|
||||
}
|
||||
|
||||
impl SpendTxState {
|
||||
pub fn new(config: Config, tx: SpendTx, saved: bool) -> Self {
|
||||
Self {
|
||||
action: choose_action(&config, saved, &tx),
|
||||
config,
|
||||
tx,
|
||||
saved,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load(&self, daemon: Arc<dyn Daemon + Sync + Send>) -> Command<Message> {
|
||||
self.action.load(daemon)
|
||||
}
|
||||
|
||||
pub fn update(
|
||||
&mut self,
|
||||
daemon: Arc<dyn Daemon + Sync + Send>,
|
||||
cache: &Cache,
|
||||
message: Message,
|
||||
) -> Command<Message> {
|
||||
let cmd = match &message {
|
||||
Message::View(view::Message::Spend(msg)) => match msg {
|
||||
view::SpendTxMessage::Cancel => {
|
||||
self.action = choose_action(&self.config, self.saved, &self.tx);
|
||||
self.action.load(daemon.clone())
|
||||
}
|
||||
view::SpendTxMessage::Delete => {
|
||||
self.action = Box::new(DeleteAction::default());
|
||||
self.action.load(daemon.clone())
|
||||
}
|
||||
_ => self
|
||||
.action
|
||||
.update(daemon.clone(), cache, message, &mut self.tx),
|
||||
},
|
||||
_ => self
|
||||
.action
|
||||
.update(daemon.clone(), cache, message, &mut self.tx),
|
||||
};
|
||||
if self.action.updated() {
|
||||
self.saved = true;
|
||||
self.action = choose_action(&self.config, self.saved, &self.tx);
|
||||
self.action.load(daemon)
|
||||
} else {
|
||||
cmd
|
||||
}
|
||||
}
|
||||
|
||||
pub fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> {
|
||||
detail::spend_view(
|
||||
self.action.warning(),
|
||||
&self.tx,
|
||||
self.action.view(),
|
||||
self.saved,
|
||||
cache.network,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn choose_action(config: &Config, saved: bool, tx: &SpendTx) -> Box<dyn Action> {
|
||||
if saved {
|
||||
match tx.status {
|
||||
SpendStatus::Deprecated | SpendStatus::Broadcasted => {
|
||||
return Box::new(NoAction::default());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if !tx.psbt.inputs.first().unwrap().partial_sigs.is_empty() {
|
||||
return Box::new(BroadcastAction::default());
|
||||
} else {
|
||||
return Box::new(SignAction::new(config.clone()));
|
||||
}
|
||||
}
|
||||
Box::new(SaveAction::default())
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct SaveAction {
|
||||
saved: bool,
|
||||
error: Option<Error>,
|
||||
}
|
||||
|
||||
impl Action for SaveAction {
|
||||
fn warning(&self) -> Option<&Error> {
|
||||
self.error.as_ref()
|
||||
}
|
||||
|
||||
fn updated(&self) -> bool {
|
||||
self.saved
|
||||
}
|
||||
|
||||
fn update(
|
||||
&mut self,
|
||||
daemon: Arc<dyn Daemon + Sync + Send>,
|
||||
_cache: &Cache,
|
||||
message: Message,
|
||||
tx: &mut SpendTx,
|
||||
) -> Command<Message> {
|
||||
match message {
|
||||
Message::View(view::Message::Spend(view::SpendTxMessage::Confirm)) => {
|
||||
let daemon = daemon.clone();
|
||||
let psbt = tx.psbt.clone();
|
||||
return Command::perform(
|
||||
async move { daemon.update_spend_tx(&psbt).map_err(|e| e.into()) },
|
||||
Message::Updated,
|
||||
);
|
||||
}
|
||||
Message::Updated(res) => match res {
|
||||
Ok(()) => self.saved = true,
|
||||
Err(e) => self.error = Some(e),
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
Command::none()
|
||||
}
|
||||
fn view(&self) -> Element<view::Message> {
|
||||
detail::save_action(self.saved)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct BroadcastAction {
|
||||
broadcasted: bool,
|
||||
error: Option<Error>,
|
||||
}
|
||||
|
||||
impl Action for BroadcastAction {
|
||||
fn warning(&self) -> Option<&Error> {
|
||||
self.error.as_ref()
|
||||
}
|
||||
fn update(
|
||||
&mut self,
|
||||
daemon: Arc<dyn Daemon + Sync + Send>,
|
||||
_cache: &Cache,
|
||||
message: Message,
|
||||
tx: &mut SpendTx,
|
||||
) -> Command<Message> {
|
||||
match message {
|
||||
Message::View(view::Message::Spend(view::SpendTxMessage::Confirm)) => {
|
||||
let daemon = daemon.clone();
|
||||
let psbt = tx.psbt.clone();
|
||||
self.error = None;
|
||||
return Command::perform(
|
||||
async move {
|
||||
daemon
|
||||
.broadcast_spend_tx(&psbt.unsigned_tx.txid())
|
||||
.map_err(|e| e.into())
|
||||
},
|
||||
Message::Updated,
|
||||
);
|
||||
}
|
||||
Message::Updated(res) => match res {
|
||||
Ok(()) => self.broadcasted = true,
|
||||
Err(e) => self.error = Some(e),
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
Command::none()
|
||||
}
|
||||
fn view(&self) -> Element<view::Message> {
|
||||
detail::broadcast_action(self.broadcasted)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct DeleteAction {
|
||||
deleted: bool,
|
||||
error: Option<Error>,
|
||||
}
|
||||
|
||||
impl Action for DeleteAction {
|
||||
fn warning(&self) -> Option<&Error> {
|
||||
self.error.as_ref()
|
||||
}
|
||||
|
||||
fn update(
|
||||
&mut self,
|
||||
daemon: Arc<dyn Daemon + Sync + Send>,
|
||||
_cache: &Cache,
|
||||
message: Message,
|
||||
tx: &mut SpendTx,
|
||||
) -> Command<Message> {
|
||||
match message {
|
||||
Message::View(view::Message::Spend(view::SpendTxMessage::Confirm)) => {
|
||||
let daemon = daemon.clone();
|
||||
let psbt = tx.psbt.clone();
|
||||
self.error = None;
|
||||
return Command::perform(
|
||||
async move {
|
||||
daemon
|
||||
.delete_spend_tx(&psbt.unsigned_tx.txid())
|
||||
.map_err(|e| e.into())
|
||||
},
|
||||
Message::Updated,
|
||||
);
|
||||
}
|
||||
Message::Updated(res) => match res {
|
||||
Ok(()) => self.deleted = true,
|
||||
Err(e) => self.error = Some(e),
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
Command::none()
|
||||
}
|
||||
fn view(&self) -> Element<view::Message> {
|
||||
detail::delete_action(self.deleted)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SignAction {
|
||||
config: Config,
|
||||
chosen_hw: Option<usize>,
|
||||
processing: bool,
|
||||
hws: Vec<HardwareWallet>,
|
||||
error: Option<Error>,
|
||||
signed: Vec<Fingerprint>,
|
||||
updated: bool,
|
||||
}
|
||||
|
||||
impl SignAction {
|
||||
pub fn new(config: Config) -> Self {
|
||||
Self {
|
||||
config,
|
||||
chosen_hw: None,
|
||||
processing: false,
|
||||
hws: Vec::new(),
|
||||
error: None,
|
||||
signed: Vec::new(),
|
||||
updated: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Action for SignAction {
|
||||
fn warning(&self) -> Option<&Error> {
|
||||
self.error.as_ref()
|
||||
}
|
||||
|
||||
fn updated(&self) -> bool {
|
||||
self.updated
|
||||
}
|
||||
|
||||
fn load(&self, daemon: Arc<dyn Daemon + Sync + Send>) -> Command<Message> {
|
||||
let config = self.config.clone();
|
||||
let desc = daemon.config().main_descriptor.to_string();
|
||||
Command::perform(
|
||||
list_hws(config, "Minisafe".to_string(), desc),
|
||||
Message::ConnectedHardwareWallets,
|
||||
)
|
||||
}
|
||||
fn update(
|
||||
&mut self,
|
||||
daemon: Arc<dyn Daemon + Sync + Send>,
|
||||
_cache: &Cache,
|
||||
message: Message,
|
||||
tx: &mut SpendTx,
|
||||
) -> Command<Message> {
|
||||
match message {
|
||||
Message::View(view::Message::Spend(view::SpendTxMessage::SelectHardwareWallet(i))) => {
|
||||
if let Some(hw) = self.hws.get(i) {
|
||||
let device = hw.device.clone();
|
||||
self.chosen_hw = Some(i);
|
||||
self.processing = true;
|
||||
let psbt = tx.psbt.clone();
|
||||
return Command::perform(
|
||||
sign_psbt(device, hw.fingerprint, psbt),
|
||||
Message::Signed,
|
||||
);
|
||||
}
|
||||
}
|
||||
Message::Signed(res) => match res {
|
||||
Err(e) => self.error = Some(e),
|
||||
Ok((psbt, fingerprint)) => {
|
||||
self.error = None;
|
||||
self.signed.push(fingerprint);
|
||||
let daemon = daemon.clone();
|
||||
tx.psbt = psbt.clone();
|
||||
return Command::perform(
|
||||
async move { daemon.update_spend_tx(&psbt).map_err(|e| e.into()) },
|
||||
Message::Updated,
|
||||
);
|
||||
}
|
||||
},
|
||||
Message::Updated(res) => match res {
|
||||
Ok(()) => self.updated = true,
|
||||
Err(e) => self.error = Some(e),
|
||||
},
|
||||
Message::ConnectedHardwareWallets(hws) => {
|
||||
self.hws = hws;
|
||||
}
|
||||
Message::View(view::Message::Reload) => {
|
||||
return self.load(daemon);
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
Command::none()
|
||||
}
|
||||
fn view(&self) -> Element<view::Message> {
|
||||
view::spend::detail::sign_action(&self.hws, self.processing, self.chosen_hw, &self.signed)
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_hws(config: Config, wallet_name: String, descriptor: String) -> Vec<HardwareWallet> {
|
||||
list_hardware_wallets(&config.hardware_wallets, Some((&wallet_name, &descriptor))).await
|
||||
}
|
||||
|
||||
async fn sign_psbt(
|
||||
hw: std::sync::Arc<dyn async_hwi::HWI + Send + Sync>,
|
||||
fingerprint: Fingerprint,
|
||||
mut psbt: Psbt,
|
||||
) -> Result<(Psbt, Fingerprint), Error> {
|
||||
hw.sign_tx(&mut psbt).await.map_err(Error::from)?;
|
||||
Ok((psbt, fingerprint))
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct NoAction {}
|
||||
|
||||
impl Action for NoAction {
|
||||
fn view(&self) -> Element<view::Message> {
|
||||
iced::pure::column().into()
|
||||
}
|
||||
}
|
||||
178
gui/src/app/state/spend/mod.rs
Normal file
178
gui/src/app/state/spend/mod.rs
Normal file
@ -0,0 +1,178 @@
|
||||
mod detail;
|
||||
mod step;
|
||||
use std::sync::Arc;
|
||||
|
||||
use iced::{pure::Element, Command};
|
||||
|
||||
use super::{redirect, State};
|
||||
use crate::{
|
||||
app::{cache::Cache, config::Config, error::Error, menu::Menu, message::Message, view},
|
||||
daemon::{
|
||||
model::{Coin, SpendTx},
|
||||
Daemon,
|
||||
},
|
||||
};
|
||||
|
||||
pub struct SpendPanel {
|
||||
config: Config,
|
||||
selected_tx: Option<detail::SpendTxState>,
|
||||
spend_txs: Vec<SpendTx>,
|
||||
warning: Option<Error>,
|
||||
}
|
||||
|
||||
impl SpendPanel {
|
||||
pub fn new(config: Config, spend_txs: &[SpendTx]) -> Self {
|
||||
Self {
|
||||
config,
|
||||
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> {
|
||||
if let Some(tx) = &self.selected_tx {
|
||||
tx.view(cache)
|
||||
} else {
|
||||
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::Close) => {
|
||||
if self.selected_tx.is_some() {
|
||||
self.selected_tx = None;
|
||||
return self.load(daemon);
|
||||
}
|
||||
}
|
||||
Message::View(view::Message::Select(i)) => {
|
||||
if let Some(tx) = self.spend_txs.get(i) {
|
||||
let tx = detail::SpendTxState::new(self.config.clone(), tx.clone(), true);
|
||||
let cmd = tx.load(daemon);
|
||||
self.selected_tx = Some(tx);
|
||||
return cmd;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(tx) = &mut self.selected_tx {
|
||||
return tx.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_spend_transactions().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 {
|
||||
draft: step::TransactionDraft,
|
||||
current: usize,
|
||||
steps: Vec<Box<dyn step::Step>>,
|
||||
}
|
||||
|
||||
impl CreateSpendPanel {
|
||||
pub fn new(config: Config, coins: &[Coin]) -> Self {
|
||||
Self {
|
||||
draft: step::TransactionDraft::default(),
|
||||
current: 0,
|
||||
steps: vec![
|
||||
Box::new(step::ChooseRecipients::default()),
|
||||
Box::new(step::ChooseCoins::new(coins.to_vec())),
|
||||
Box::new(step::ChooseFeerate::default()),
|
||||
Box::new(step::SaveSpend::new(config)),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 matches!(message, Message::View(view::Message::Next)) {
|
||||
if let Some(step) = self.steps.get(self.current) {
|
||||
step.apply(&mut self.draft);
|
||||
}
|
||||
|
||||
if let Some(step) = self.steps.get_mut(self.current + 1) {
|
||||
self.current += 1;
|
||||
step.load(&self.draft);
|
||||
}
|
||||
}
|
||||
|
||||
if matches!(message, Message::View(view::Message::Previous))
|
||||
&& self.steps.get(self.current - 1).is_some()
|
||||
{
|
||||
self.current -= 1;
|
||||
}
|
||||
|
||||
if let Some(step) = self.steps.get_mut(self.current) {
|
||||
return step.update(daemon, cache, &self.draft, 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)
|
||||
}
|
||||
}
|
||||
381
gui/src/app/state/spend/step.rs
Normal file
381
gui/src/app/state/spend/step.rs
Normal file
@ -0,0 +1,381 @@
|
||||
use std::collections::HashMap;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use iced::pure::Element;
|
||||
use iced::Command;
|
||||
use minisafe::miniscript::bitcoin::{
|
||||
util::psbt::Psbt, Address, Amount, Denomination, OutPoint, Script,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
app::{
|
||||
cache::Cache, config::Config, error::Error, message::Message, state::spend::detail, view,
|
||||
},
|
||||
daemon::{
|
||||
model::{Coin, SpendTx},
|
||||
Daemon,
|
||||
},
|
||||
ui::component::form,
|
||||
};
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
pub struct TransactionDraft {
|
||||
inputs: Vec<Coin>,
|
||||
outputs: HashMap<Address, u64>,
|
||||
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,
|
||||
draft: &TransactionDraft,
|
||||
message: Message,
|
||||
) -> Command<Message>;
|
||||
fn apply(&self, _draft: &mut TransactionDraft) {}
|
||||
fn load(&mut self, _draft: &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,
|
||||
_draft: &TransactionDraft,
|
||||
message: Message,
|
||||
) -> Command<Message> {
|
||||
if let Message::View(view::Message::CreateSpend(msg)) = message {
|
||||
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, u64> = 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;
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ChooseFeerate {
|
||||
feerate: form::Value<String>,
|
||||
generated: Option<Psbt>,
|
||||
warning: Option<Error>,
|
||||
}
|
||||
|
||||
impl Step for ChooseFeerate {
|
||||
fn update(
|
||||
&mut self,
|
||||
daemon: Arc<dyn Daemon + Sync + Send>,
|
||||
_cache: &Cache,
|
||||
draft: &TransactionDraft,
|
||||
message: Message,
|
||||
) -> Command<Message> {
|
||||
match message {
|
||||
Message::View(view::Message::CreateSpend(view::CreateSpendMessage::FeerateEdited(
|
||||
s,
|
||||
))) => {
|
||||
if s.parse::<u64>().is_ok() {
|
||||
self.feerate.value = s;
|
||||
self.feerate.valid = true;
|
||||
} else if s.is_empty() {
|
||||
self.feerate.value = "".to_string();
|
||||
self.feerate.valid = true;
|
||||
} else {
|
||||
self.feerate.valid = false;
|
||||
}
|
||||
self.warning = None;
|
||||
}
|
||||
Message::View(view::Message::CreateSpend(view::CreateSpendMessage::Generate)) => {
|
||||
let inputs: Vec<OutPoint> = draft.inputs.iter().map(|c| c.outpoint).collect();
|
||||
let outputs = draft.outputs.clone();
|
||||
let feerate_vb = self.feerate.value.parse::<u64>().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),
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Command::none()
|
||||
}
|
||||
|
||||
fn apply(&self, draft: &mut TransactionDraft) {
|
||||
draft.feerate = self.feerate.value.parse::<u64>().expect("Checked before");
|
||||
draft.generated = self.generated.clone();
|
||||
}
|
||||
|
||||
fn view<'a>(&'a self, _cache: &'a Cache) -> Element<'a, view::Message> {
|
||||
view::spend::step::choose_feerate_view(
|
||||
&self.feerate,
|
||||
self.feerate.valid && !self.feerate.value.is_empty(),
|
||||
self.warning.as_ref(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ChooseCoins {
|
||||
coins: Vec<(Coin, bool)>,
|
||||
/// draft output amount must be superior to total input amount.
|
||||
is_valid: bool,
|
||||
total_needed: Option<Amount>,
|
||||
}
|
||||
|
||||
impl ChooseCoins {
|
||||
pub fn new(coins: Vec<Coin>) -> Self {
|
||||
Self {
|
||||
coins: coins
|
||||
.into_iter()
|
||||
.filter_map(|c| {
|
||||
if c.spend_info.is_none() {
|
||||
Some((c, false))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
is_valid: false,
|
||||
total_needed: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Step for ChooseCoins {
|
||||
fn load(&mut self, draft: &TransactionDraft) {
|
||||
self.total_needed = Some(Amount::from_sat(
|
||||
draft.outputs.values().fold(0, |acc, a| acc + *a),
|
||||
));
|
||||
}
|
||||
|
||||
fn update(
|
||||
&mut self,
|
||||
_daemon: Arc<dyn Daemon + Sync + Send>,
|
||||
_cache: &Cache,
|
||||
_draft: &TransactionDraft,
|
||||
message: Message,
|
||||
) -> Command<Message> {
|
||||
if let Message::View(view::Message::CreateSpend(view::CreateSpendMessage::SelectCoin(i))) =
|
||||
message
|
||||
{
|
||||
if let Some(coin) = self.coins.get_mut(i) {
|
||||
coin.1 = !coin.1;
|
||||
}
|
||||
|
||||
self.is_valid = self
|
||||
.coins
|
||||
.iter()
|
||||
.filter_map(|(coin, selected)| {
|
||||
if *selected {
|
||||
Some(coin.amount.to_sat())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.sum::<u64>()
|
||||
> self.total_needed.map(|a| a.to_sat()).unwrap_or(0);
|
||||
}
|
||||
|
||||
Command::none()
|
||||
}
|
||||
|
||||
fn apply(&self, draft: &mut TransactionDraft) {
|
||||
draft.inputs = self
|
||||
.coins
|
||||
.iter()
|
||||
.filter_map(|(coin, selected)| if *selected { Some(*coin) } else { None })
|
||||
.collect();
|
||||
}
|
||||
|
||||
fn view<'a>(&'a self, _cache: &'a Cache) -> Element<'a, view::Message> {
|
||||
view::spend::step::choose_coins_view(&self.coins, self.total_needed.as_ref(), self.is_valid)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SaveSpend {
|
||||
config: Config,
|
||||
spend: Option<detail::SpendTxState>,
|
||||
}
|
||||
|
||||
impl SaveSpend {
|
||||
pub fn new(config: Config) -> Self {
|
||||
Self {
|
||||
config,
|
||||
spend: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Step for SaveSpend {
|
||||
fn load(&mut self, draft: &TransactionDraft) {
|
||||
let outputs_script_pubkeys: Vec<Script> = draft
|
||||
.outputs
|
||||
.keys()
|
||||
.map(|addr| addr.script_pubkey())
|
||||
.collect();
|
||||
let index = if let Some(psbt) = &draft.generated {
|
||||
psbt.unsigned_tx
|
||||
.output
|
||||
.iter()
|
||||
.position(|output| outputs_script_pubkeys.contains(&output.script_pubkey))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.spend = Some(detail::SpendTxState::new(
|
||||
self.config.clone(),
|
||||
SpendTx::new(
|
||||
draft.generated.clone().unwrap(),
|
||||
index,
|
||||
draft.inputs.clone(),
|
||||
),
|
||||
false,
|
||||
));
|
||||
}
|
||||
|
||||
fn update(
|
||||
&mut self,
|
||||
daemon: Arc<dyn Daemon + Sync + Send>,
|
||||
cache: &Cache,
|
||||
_draft: &TransactionDraft,
|
||||
message: Message,
|
||||
) -> Command<Message> {
|
||||
if let Some(spend) = &mut self.spend {
|
||||
spend.update(daemon, cache, message)
|
||||
} else {
|
||||
Command::none()
|
||||
}
|
||||
}
|
||||
|
||||
fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> {
|
||||
self.spend.as_ref().unwrap().view(cache)
|
||||
}
|
||||
}
|
||||
@ -3,7 +3,10 @@ use iced::{
|
||||
Alignment, Length,
|
||||
};
|
||||
|
||||
use crate::ui::component::{badge, button::Style, card, text::*};
|
||||
use crate::ui::{
|
||||
component::{badge, button::Style, card, text::*},
|
||||
util::Collection,
|
||||
};
|
||||
|
||||
use crate::{app::view::message::Message, daemon::model::Coin};
|
||||
|
||||
@ -39,7 +42,11 @@ fn coin_list_view<'a>(i: usize, coin: &Coin) -> Element<'a, Message> {
|
||||
.push(
|
||||
row()
|
||||
.push(badge::coin())
|
||||
.push(text(&format!("block: {}", coin.block_height.unwrap_or(0))).small())
|
||||
.push_maybe(coin.spend_info.map(|_| {
|
||||
container(text(" Spent ").small())
|
||||
.padding(3)
|
||||
.style(badge::PillStyle::Success)
|
||||
}))
|
||||
.spacing(10)
|
||||
.align_items(Alignment::Center)
|
||||
.width(Length::Fill),
|
||||
|
||||
@ -8,6 +8,28 @@ pub enum Message {
|
||||
Close,
|
||||
Select(usize),
|
||||
Settings(usize, SettingsMessage),
|
||||
CreateSpend(CreateSpendMessage),
|
||||
Spend(SpendTxMessage),
|
||||
Next,
|
||||
Previous,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum CreateSpendMessage {
|
||||
AddRecipient,
|
||||
DeleteRecipient(usize),
|
||||
SelectCoin(usize),
|
||||
RecipientEdited(usize, &'static str, String),
|
||||
FeerateEdited(String),
|
||||
Generate,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SpendTxMessage {
|
||||
Delete,
|
||||
Confirm,
|
||||
Cancel,
|
||||
SelectHardwareWallet(usize),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
||||
@ -5,6 +5,7 @@ pub mod coins;
|
||||
pub mod home;
|
||||
pub mod receive;
|
||||
pub mod settings;
|
||||
pub mod spend;
|
||||
|
||||
pub use message::*;
|
||||
use warning::warn;
|
||||
@ -16,13 +17,14 @@ use iced::{
|
||||
|
||||
use crate::ui::{
|
||||
color,
|
||||
component::{button, separation, text::*},
|
||||
icon::{coin_icon, home_icon, receive_icon, settings_icon},
|
||||
component::{badge, button, separation, text::*},
|
||||
icon::{coin_icon, cross_icon, home_icon, receive_icon, send_icon, settings_icon},
|
||||
util::Collection,
|
||||
};
|
||||
|
||||
use crate::app::{error::Error, menu::Menu};
|
||||
use crate::app::{cache::Cache, error::Error, menu::Menu};
|
||||
|
||||
pub fn sidebar(menu: &Menu) -> widget::Container<Message> {
|
||||
pub fn sidebar<'a>(menu: &Menu, cache: &'a Cache) -> widget::Container<'a, Message> {
|
||||
let home_button = if *menu == Menu::Home {
|
||||
button::primary(Some(home_icon()), "Home")
|
||||
.on_press(Message::Reload)
|
||||
@ -34,13 +36,155 @@ pub fn sidebar(menu: &Menu) -> widget::Container<Message> {
|
||||
};
|
||||
|
||||
let coins_button = if *menu == Menu::Coins {
|
||||
button::primary(Some(coin_icon()), "Coins")
|
||||
.on_press(Message::Reload)
|
||||
.width(iced::Length::Units(200))
|
||||
iced::pure::widget::button::Button::new(
|
||||
container(
|
||||
row()
|
||||
.push(
|
||||
row()
|
||||
.push(coin_icon())
|
||||
.push(text("Coins"))
|
||||
.spacing(10)
|
||||
.width(iced::Length::Fill)
|
||||
.align_items(iced::Alignment::Center),
|
||||
)
|
||||
.push(
|
||||
container(
|
||||
text(&format!(
|
||||
" {} ",
|
||||
cache
|
||||
.coins
|
||||
.iter()
|
||||
// TODO: Remove when cache contains only current coins.
|
||||
.filter(|coin| coin.spend_info.is_none())
|
||||
.count()
|
||||
))
|
||||
.small()
|
||||
.bold(),
|
||||
)
|
||||
.style(badge::PillStyle::InversePrimary),
|
||||
)
|
||||
.spacing(10)
|
||||
.width(iced::Length::Fill)
|
||||
.align_items(iced::Alignment::Center),
|
||||
)
|
||||
.width(iced::Length::Fill)
|
||||
.padding(5)
|
||||
.center_x(),
|
||||
)
|
||||
.style(button::Style::Primary)
|
||||
.on_press(Message::Reload)
|
||||
.width(iced::Length::Units(200))
|
||||
} else {
|
||||
button::transparent(Some(coin_icon()), "Coins")
|
||||
.on_press(Message::Menu(Menu::Coins))
|
||||
.width(iced::Length::Units(200))
|
||||
iced::pure::widget::button::Button::new(
|
||||
container(
|
||||
row()
|
||||
.push(
|
||||
row()
|
||||
.push(coin_icon())
|
||||
.push(text("Coins"))
|
||||
.spacing(10)
|
||||
.width(iced::Length::Fill)
|
||||
.align_items(iced::Alignment::Center),
|
||||
)
|
||||
.push(
|
||||
container(
|
||||
text(&format!(
|
||||
" {} ",
|
||||
cache
|
||||
.coins
|
||||
.iter()
|
||||
// TODO: Remove when cache contains only current coins.
|
||||
.filter(|coin| coin.spend_info.is_none())
|
||||
.count()
|
||||
))
|
||||
.small()
|
||||
.bold(),
|
||||
)
|
||||
.style(badge::PillStyle::Primary),
|
||||
)
|
||||
.spacing(10)
|
||||
.width(iced::Length::Fill)
|
||||
.align_items(iced::Alignment::Center),
|
||||
)
|
||||
.width(iced::Length::Fill)
|
||||
.padding(5)
|
||||
.center_x(),
|
||||
)
|
||||
.style(button::Style::Transparent)
|
||||
.on_press(Message::Menu(Menu::Coins))
|
||||
.width(iced::Length::Units(200))
|
||||
};
|
||||
|
||||
let spend_button = if *menu == Menu::Spend {
|
||||
iced::pure::widget::button::Button::new(
|
||||
container(
|
||||
row()
|
||||
.push(
|
||||
row()
|
||||
.push(send_icon())
|
||||
.push(text("Send"))
|
||||
.spacing(10)
|
||||
.width(iced::Length::Fill)
|
||||
.align_items(iced::Alignment::Center),
|
||||
)
|
||||
.push_maybe(if cache.spend_txs.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
container(
|
||||
text(&format!(" {} ", cache.spend_txs.len()))
|
||||
.small()
|
||||
.bold(),
|
||||
)
|
||||
.style(badge::PillStyle::InversePrimary),
|
||||
)
|
||||
})
|
||||
.spacing(10)
|
||||
.width(iced::Length::Fill)
|
||||
.align_items(iced::Alignment::Center),
|
||||
)
|
||||
.width(iced::Length::Fill)
|
||||
.padding(5)
|
||||
.center_x(),
|
||||
)
|
||||
.style(button::Style::Primary)
|
||||
.on_press(Message::Reload)
|
||||
.width(iced::Length::Units(200))
|
||||
} else {
|
||||
iced::pure::widget::button::Button::new(
|
||||
container(
|
||||
row()
|
||||
.push(
|
||||
row()
|
||||
.push(send_icon())
|
||||
.push(text("Send"))
|
||||
.spacing(10)
|
||||
.width(iced::Length::Fill)
|
||||
.align_items(iced::Alignment::Center),
|
||||
)
|
||||
.push_maybe(if cache.spend_txs.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
container(
|
||||
text(&format!(" {} ", cache.spend_txs.len()))
|
||||
.small()
|
||||
.bold(),
|
||||
)
|
||||
.style(badge::PillStyle::Primary),
|
||||
)
|
||||
})
|
||||
.spacing(10)
|
||||
.width(iced::Length::Fill)
|
||||
.align_items(iced::Alignment::Center),
|
||||
)
|
||||
.width(iced::Length::Fill)
|
||||
.padding(5)
|
||||
.center_x(),
|
||||
)
|
||||
.style(button::Style::Transparent)
|
||||
.on_press(Message::Menu(Menu::Spend))
|
||||
.width(iced::Length::Units(200))
|
||||
};
|
||||
|
||||
let receive_button = if *menu == Menu::Receive {
|
||||
@ -76,6 +220,7 @@ pub fn sidebar(menu: &Menu) -> widget::Container<Message> {
|
||||
)
|
||||
.push(home_button)
|
||||
.push(coins_button)
|
||||
.push(spend_button)
|
||||
.push(receive_button)
|
||||
.spacing(15)
|
||||
.height(Length::Fill),
|
||||
@ -99,11 +244,16 @@ impl widget::container::StyleSheet for SidebarStyle {
|
||||
|
||||
pub fn dashboard<'a, T: Into<Element<'a, Message>>>(
|
||||
menu: &'a Menu,
|
||||
cache: &'a Cache,
|
||||
warning: Option<&Error>,
|
||||
content: T,
|
||||
) -> Element<'a, Message> {
|
||||
row()
|
||||
.push(sidebar(menu).width(Length::Shrink).height(Length::Fill))
|
||||
.push(
|
||||
sidebar(menu, cache)
|
||||
.width(Length::Shrink)
|
||||
.height(Length::Fill),
|
||||
)
|
||||
.push(
|
||||
column().push(warn(warning)).push(
|
||||
main_section(container(scrollable(content)))
|
||||
@ -134,3 +284,53 @@ 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").on_press(Message::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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,7 +12,7 @@ use super::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
app::{error::Error, menu::Menu},
|
||||
app::{cache::Cache, error::Error, menu::Menu},
|
||||
ui::{
|
||||
color,
|
||||
component::{badge, button, card, form, separation, text::*},
|
||||
@ -21,11 +21,13 @@ use crate::{
|
||||
};
|
||||
|
||||
pub fn list<'a>(
|
||||
cache: &'a Cache,
|
||||
warning: Option<&Error>,
|
||||
settings: Vec<Element<'a, Message>>,
|
||||
) -> Element<'a, Message> {
|
||||
dashboard(
|
||||
&Menu::Settings,
|
||||
cache,
|
||||
warning,
|
||||
widget::Column::with_children(settings).spacing(20),
|
||||
)
|
||||
|
||||
412
gui/src/app/view/spend/detail.rs
Normal file
412
gui/src/app/view/spend/detail.rs
Normal file
@ -0,0 +1,412 @@
|
||||
use iced::{
|
||||
pure::{column, container, row, scrollable, Element},
|
||||
Alignment, Length,
|
||||
};
|
||||
|
||||
use minisafe::miniscript::bitcoin::{
|
||||
util::{bip32::Fingerprint, psbt::Psbt},
|
||||
Address, Amount, Network,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
app::{
|
||||
error::Error,
|
||||
view::{message::*, modal_section, warning::warn, ModalSectionStyle},
|
||||
},
|
||||
daemon::model::{Coin, SpendStatus, SpendTx},
|
||||
hw::HardwareWallet,
|
||||
ui::{
|
||||
color,
|
||||
component::{
|
||||
badge, button, card, separation,
|
||||
text::{text, Text},
|
||||
},
|
||||
icon,
|
||||
util::Collection,
|
||||
},
|
||||
};
|
||||
|
||||
pub fn spend_view<'a, T: Into<Element<'a, Message>>>(
|
||||
warning: Option<&Error>,
|
||||
tx: &SpendTx,
|
||||
action: T,
|
||||
show_delete: bool,
|
||||
network: Network,
|
||||
) -> Element<'a, Message> {
|
||||
spend_modal(
|
||||
show_delete,
|
||||
warning,
|
||||
column()
|
||||
.spacing(20)
|
||||
.push(spend_overview_view(tx))
|
||||
.push(action)
|
||||
.push(inputs_and_outputs_view(
|
||||
&tx.coins,
|
||||
&tx.psbt,
|
||||
network,
|
||||
tx.change_index,
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn save_action<'a>(saved: bool) -> Element<'a, Message> {
|
||||
if saved {
|
||||
card::simple(text("Transaction is saved"))
|
||||
.width(Length::Fill)
|
||||
.align_x(iced::alignment::Horizontal::Center)
|
||||
.into()
|
||||
} else {
|
||||
card::simple(
|
||||
column()
|
||||
.spacing(10)
|
||||
.push(text("Save the transaction"))
|
||||
.push(row().push(column().width(Length::Fill)).push(
|
||||
button::primary(None, "Save").on_press(Message::Spend(SpendTxMessage::Confirm)),
|
||||
)),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn broadcast_action<'a>(saved: bool) -> Element<'a, Message> {
|
||||
if saved {
|
||||
card::simple(text("Transaction is broadcasted"))
|
||||
.width(Length::Fill)
|
||||
.align_x(iced::alignment::Horizontal::Center)
|
||||
.into()
|
||||
} else {
|
||||
card::simple(
|
||||
column()
|
||||
.spacing(10)
|
||||
.push(text("Broadcast the transaction"))
|
||||
.push(
|
||||
row().push(column().width(Length::Fill)).push(
|
||||
button::primary(None, "Broadcast")
|
||||
.on_press(Message::Spend(SpendTxMessage::Confirm)),
|
||||
),
|
||||
),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_action<'a>(deleted: bool) -> Element<'a, Message> {
|
||||
if deleted {
|
||||
card::simple(text("Transaction is deleted"))
|
||||
.align_x(iced::alignment::Horizontal::Center)
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
} else {
|
||||
card::simple(
|
||||
column()
|
||||
.spacing(10)
|
||||
.push(text("Delete the transaction draft"))
|
||||
.push(
|
||||
row()
|
||||
.push(column().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::Fill)
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn spend_modal<'a, T: Into<Element<'a, Message>>>(
|
||||
show_delete: bool,
|
||||
warning: Option<&Error>,
|
||||
content: T,
|
||||
) -> Element<'a, Message> {
|
||||
column()
|
||||
.push(warn(warning))
|
||||
.push(
|
||||
container(
|
||||
row()
|
||||
.push(if show_delete {
|
||||
column()
|
||||
.push(
|
||||
button::alert(Some(icon::trash_icon()), "Delete")
|
||||
.on_press(Message::Spend(SpendTxMessage::Delete)),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
} else {
|
||||
column().width(Length::Fill)
|
||||
})
|
||||
.align_items(iced::Alignment::Center)
|
||||
.push(
|
||||
button::primary(Some(icon::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 spend_overview_view<'a>(tx: &SpendTx) -> Element<'a, Message> {
|
||||
column()
|
||||
.spacing(20)
|
||||
.align_items(Alignment::Center)
|
||||
.push(
|
||||
row()
|
||||
.push(badge::Badge::new(icon::send_icon()).style(badge::Style::Standard))
|
||||
.push(text("Spend").bold())
|
||||
.spacing(5)
|
||||
.align_items(Alignment::Center),
|
||||
)
|
||||
.push_maybe(match tx.status {
|
||||
SpendStatus::Deprecated => Some(
|
||||
container(text(" Deprecated ").small())
|
||||
.padding(3)
|
||||
.style(badge::PillStyle::Simple),
|
||||
),
|
||||
SpendStatus::Broadcasted => Some(
|
||||
container(text(" Broadcasted ").small())
|
||||
.padding(3)
|
||||
.style(badge::PillStyle::Success),
|
||||
),
|
||||
_ => None,
|
||||
})
|
||||
.push(
|
||||
column()
|
||||
.align_items(Alignment::Center)
|
||||
.push(
|
||||
text(&format!("- {} BTC", tx.spend_amount.to_btc()))
|
||||
.bold()
|
||||
.size(50),
|
||||
)
|
||||
.push(container(text(&format!(
|
||||
"Miner Fee: {} BTC",
|
||||
tx.fee_amount.to_btc()
|
||||
)))),
|
||||
)
|
||||
.push(card::simple(
|
||||
column()
|
||||
.push(container(
|
||||
row()
|
||||
.push(
|
||||
container(
|
||||
row()
|
||||
.push(container(icon::key_icon().size(30).width(Length::Fill)))
|
||||
.push(column().push(text("Number of signatures:").bold()).push(
|
||||
text(&format!("{}", tx.psbt.inputs[0].partial_sigs.len(),)),
|
||||
))
|
||||
.align_items(Alignment::Center)
|
||||
.spacing(20),
|
||||
)
|
||||
.width(Length::FillPortion(1)),
|
||||
)
|
||||
.align_items(Alignment::Center)
|
||||
.spacing(20),
|
||||
))
|
||||
.push(separation().width(Length::Fill))
|
||||
.push(
|
||||
column()
|
||||
.push(
|
||||
row()
|
||||
.push(text("Tx ID:").bold().width(Length::Fill))
|
||||
.push(text(&format!("{}", tx.psbt.unsigned_tx.txid())).small())
|
||||
.align_items(Alignment::Center),
|
||||
)
|
||||
.push(
|
||||
row()
|
||||
.push(text("Psbt:").bold().width(Length::Fill))
|
||||
.push(
|
||||
button::transparent(Some(icon::clipboard_icon()), "Copy")
|
||||
.on_press(Message::Clipboard(tx.psbt.to_string())),
|
||||
)
|
||||
.align_items(Alignment::Center),
|
||||
),
|
||||
)
|
||||
.spacing(20),
|
||||
))
|
||||
.into()
|
||||
}
|
||||
|
||||
fn inputs_and_outputs_view<'a>(
|
||||
coins: &[Coin],
|
||||
psbt: &Psbt,
|
||||
network: Network,
|
||||
change_index: Option<usize>,
|
||||
) -> Element<'a, Message> {
|
||||
column()
|
||||
.push(
|
||||
row()
|
||||
.spacing(10)
|
||||
.push(
|
||||
column()
|
||||
.spacing(10)
|
||||
.push(text("Spent coins:").bold())
|
||||
.push(coins.iter().fold(column().spacing(10), |col, coin| {
|
||||
col.push(
|
||||
card::simple(
|
||||
column()
|
||||
.width(Length::Fill)
|
||||
.push(text(&format!("{} BTC", coin.amount.to_btc())).bold())
|
||||
.push(text(&format!("{}", coin.outpoint)).small()),
|
||||
)
|
||||
.width(Length::Fill),
|
||||
)
|
||||
}))
|
||||
.width(Length::FillPortion(1)),
|
||||
)
|
||||
.push(
|
||||
column()
|
||||
.spacing(10)
|
||||
.push(text("Recipients:").bold())
|
||||
.push(psbt.unsigned_tx.output.iter().enumerate().fold(
|
||||
column().spacing(10),
|
||||
|col, (i, output)| {
|
||||
col.push(
|
||||
card::simple(
|
||||
column()
|
||||
.width(Length::Fill)
|
||||
.push(
|
||||
text(&format!(
|
||||
"{} BTC",
|
||||
Amount::from_sat(output.value).to_btc()
|
||||
))
|
||||
.bold(),
|
||||
)
|
||||
.push(
|
||||
text(&format!(
|
||||
"{}",
|
||||
Address::from_script(
|
||||
&output.script_pubkey,
|
||||
network
|
||||
)
|
||||
.unwrap()
|
||||
))
|
||||
.small(),
|
||||
)
|
||||
.push_maybe(if Some(i) == change_index {
|
||||
Some(
|
||||
container(text("Change"))
|
||||
.padding(5)
|
||||
.style(badge::PillStyle::Success),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
}),
|
||||
)
|
||||
.width(Length::Fill),
|
||||
)
|
||||
},
|
||||
))
|
||||
.width(Length::FillPortion(1)),
|
||||
),
|
||||
)
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn sign_action<'a>(
|
||||
hws: &[HardwareWallet],
|
||||
processing: bool,
|
||||
chosen_hw: Option<usize>,
|
||||
signed: &[Fingerprint],
|
||||
) -> Element<'a, Message> {
|
||||
card::simple(
|
||||
column()
|
||||
.push(if !hws.is_empty() {
|
||||
column()
|
||||
.push(text("Select hardware wallet to sign with:").bold())
|
||||
.spacing(10)
|
||||
.push(
|
||||
hws.iter()
|
||||
.enumerate()
|
||||
.fold(column().spacing(10), |col, (i, hw)| {
|
||||
col.push(hw_list_view(
|
||||
i,
|
||||
hw,
|
||||
Some(i) == chosen_hw,
|
||||
processing,
|
||||
signed.contains(&hw.fingerprint),
|
||||
))
|
||||
}),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
} else {
|
||||
column()
|
||||
.push(
|
||||
card::simple(
|
||||
column()
|
||||
.spacing(20)
|
||||
.width(Length::Fill)
|
||||
.push("Please connect a hardware wallet")
|
||||
.push(button::primary(None, "Refresh").on_press(Message::Reload))
|
||||
.align_items(Alignment::Center),
|
||||
)
|
||||
.width(Length::Fill),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
})
|
||||
.width(Length::Fill)
|
||||
.align_items(Alignment::Center),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn hw_list_view<'a>(
|
||||
i: usize,
|
||||
hw: &HardwareWallet,
|
||||
chosen: bool,
|
||||
processing: bool,
|
||||
signed: bool,
|
||||
) -> Element<'a, Message> {
|
||||
let mut bttn = iced::pure::button(
|
||||
row()
|
||||
.push(
|
||||
column()
|
||||
.push(text(&format!("{}", hw.kind)).bold())
|
||||
.push(text(&format!("fingerprint: {}", hw.fingerprint)).small())
|
||||
.spacing(5)
|
||||
.width(Length::Fill),
|
||||
)
|
||||
.push_maybe(if chosen && processing {
|
||||
Some(
|
||||
column()
|
||||
.push(text("Processing..."))
|
||||
.push(text("Please check your device").small()),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
})
|
||||
.push_maybe(if signed {
|
||||
Some(
|
||||
column().push(
|
||||
row()
|
||||
.spacing(5)
|
||||
.push(icon::circle_check_icon().color(color::SUCCESS))
|
||||
.push(text("Signed").color(color::SUCCESS)),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
})
|
||||
.align_items(Alignment::Center)
|
||||
.width(Length::Fill),
|
||||
)
|
||||
.padding(10)
|
||||
.style(button::Style::Border)
|
||||
.width(Length::Fill);
|
||||
if !processing {
|
||||
bttn = bttn.on_press(Message::Spend(SpendTxMessage::SelectHardwareWallet(i)));
|
||||
}
|
||||
container(bttn)
|
||||
.width(Length::Fill)
|
||||
.style(card::SimpleCardStyle)
|
||||
.into()
|
||||
}
|
||||
91
gui/src/app/view/spend/mod.rs
Normal file
91
gui/src/app/view/spend/mod.rs
Normal file
@ -0,0 +1,91 @@
|
||||
pub mod detail;
|
||||
pub mod step;
|
||||
|
||||
use iced::{
|
||||
pure::{button, column, container, row, Element},
|
||||
Alignment, Length,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
app::menu::Menu,
|
||||
daemon::model::{SpendStatus, SpendTx},
|
||||
ui::{
|
||||
component::{badge, button, card, text::*},
|
||||
icon,
|
||||
util::Collection,
|
||||
},
|
||||
};
|
||||
|
||||
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()
|
||||
.push(text(&format!(" {}", spend_txs.len())).bold())
|
||||
.push(text(" draft transactions")),
|
||||
)
|
||||
.width(Length::Fill),
|
||||
)
|
||||
.push(
|
||||
column().spacing(10).push(
|
||||
spend_txs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.fold(column().spacing(10), |col, (i, tx)| {
|
||||
col.push(spend_tx_list_view(i, tx))
|
||||
}),
|
||||
),
|
||||
)
|
||||
.align_items(Alignment::Center)
|
||||
.spacing(20)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn spend_tx_list_view<'a>(i: usize, tx: &SpendTx) -> Element<'a, Message> {
|
||||
container(
|
||||
button(
|
||||
row()
|
||||
.push(
|
||||
row()
|
||||
.push(badge::spend())
|
||||
.push_maybe(match tx.status {
|
||||
SpendStatus::Deprecated => Some(
|
||||
container(text(" Deprecated ").small())
|
||||
.padding(3)
|
||||
.style(badge::PillStyle::Simple),
|
||||
),
|
||||
SpendStatus::Broadcasted => Some(
|
||||
container(text(" Broadcasted ").small())
|
||||
.padding(3)
|
||||
.style(badge::PillStyle::Success),
|
||||
),
|
||||
_ => None,
|
||||
})
|
||||
.spacing(10)
|
||||
.align_items(Alignment::Center)
|
||||
.width(Length::Fill),
|
||||
)
|
||||
.push(
|
||||
column()
|
||||
.push(text(&format!("{} BTC", tx.spend_amount.to_btc())).bold())
|
||||
.push(text(&format!("fee: {}", tx.fee_amount.to_btc())).small())
|
||||
.width(Length::Shrink),
|
||||
)
|
||||
.align_items(Alignment::Center)
|
||||
.spacing(20),
|
||||
)
|
||||
.padding(10)
|
||||
.on_press(Message::Select(i))
|
||||
.style(button::Style::TransparentBorder),
|
||||
)
|
||||
.style(card::SimpleCardStyle)
|
||||
.into()
|
||||
}
|
||||
198
gui/src/app/view/spend/step.rs
Normal file
198
gui/src/app/view/spend/step.rs
Normal file
@ -0,0 +1,198 @@
|
||||
use iced::{
|
||||
pure::{column, container, row, widget, Element},
|
||||
Alignment, Length,
|
||||
};
|
||||
|
||||
use minisafe::miniscript::bitcoin::Amount;
|
||||
|
||||
use crate::{
|
||||
app::{
|
||||
error::Error,
|
||||
view::{message::*, modal},
|
||||
},
|
||||
daemon::model::Coin,
|
||||
ui::{
|
||||
component::{
|
||||
badge, button, card, form,
|
||||
text::{text, Text},
|
||||
},
|
||||
icon,
|
||||
util::Collection,
|
||||
},
|
||||
};
|
||||
|
||||
pub fn choose_recipients_view(
|
||||
recipients: Vec<Element<Message>>,
|
||||
is_valid: bool,
|
||||
) -> Element<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),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn choose_feerate_view<'a>(
|
||||
feerate: &form::Value<String>,
|
||||
is_valid: bool,
|
||||
error: Option<&Error>,
|
||||
) -> Element<'a, Message> {
|
||||
modal(
|
||||
true,
|
||||
None,
|
||||
column()
|
||||
.push(text("Choose feerate").bold().size(50))
|
||||
.push(
|
||||
container(
|
||||
form::Form::new("Feerate", feerate, move |msg| {
|
||||
Message::CreateSpend(CreateSpendMessage::FeerateEdited(msg))
|
||||
})
|
||||
.warning("Please enter correct feerate")
|
||||
.size(20)
|
||||
.padding(10),
|
||||
)
|
||||
.width(Length::Units(250)),
|
||||
)
|
||||
.push_maybe(error.map(|e| card::error("Failed to create spend", &e.to_string())))
|
||||
.push_maybe(if is_valid {
|
||||
Some(
|
||||
button::primary(None, "Next")
|
||||
.on_press(Message::CreateSpend(CreateSpendMessage::Generate))
|
||||
.width(Length::Units(100)),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
})
|
||||
.spacing(20)
|
||||
.align_items(Alignment::Center),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn choose_coins_view<'a>(
|
||||
coins: &[(Coin, bool)],
|
||||
total_needed: Option<&Amount>,
|
||||
is_valid: bool,
|
||||
) -> Element<'a, Message> {
|
||||
modal(
|
||||
true,
|
||||
None,
|
||||
column()
|
||||
.push(text("Choose coins").bold().size(50))
|
||||
.push(
|
||||
column().spacing(10).push(
|
||||
coins
|
||||
.iter()
|
||||
.enumerate()
|
||||
.fold(column().spacing(10), |col, (i, (coin, selected))| {
|
||||
col.push(coin_list_view(i, coin, *selected))
|
||||
}),
|
||||
),
|
||||
)
|
||||
.push_maybe(if is_valid {
|
||||
Some(container(
|
||||
button::primary(None, "Next")
|
||||
.on_press(Message::Next)
|
||||
.width(Length::Units(100)),
|
||||
))
|
||||
} else if total_needed.is_some() {
|
||||
Some(container(card::warning(&format!(
|
||||
"Total amount must be superior to {}",
|
||||
total_needed.unwrap().to_btc(),
|
||||
))))
|
||||
} else {
|
||||
None
|
||||
})
|
||||
.spacing(20)
|
||||
.align_items(Alignment::Center),
|
||||
)
|
||||
}
|
||||
|
||||
fn coin_list_view<'a>(i: usize, coin: &Coin, selected: bool) -> Element<'a, Message> {
|
||||
container(
|
||||
iced::pure::button(
|
||||
row()
|
||||
.push(
|
||||
row()
|
||||
.push(if selected {
|
||||
icon::square_check_icon()
|
||||
} else {
|
||||
icon::square_icon()
|
||||
})
|
||||
.push(badge::coin())
|
||||
.push(text(&format!("block: {}", coin.block_height.unwrap_or(0))).small())
|
||||
.spacing(10)
|
||||
.align_items(Alignment::Center)
|
||||
.width(Length::Fill),
|
||||
)
|
||||
.push(
|
||||
text(&format!("{} BTC", coin.amount.to_btc()))
|
||||
.bold()
|
||||
.width(Length::Shrink),
|
||||
)
|
||||
.align_items(Alignment::Center)
|
||||
.spacing(20),
|
||||
)
|
||||
.padding(10)
|
||||
.on_press(Message::CreateSpend(CreateSpendMessage::SelectCoin(i)))
|
||||
.style(button::Style::TransparentBorder),
|
||||
)
|
||||
.style(card::SimpleCardStyle)
|
||||
.into()
|
||||
}
|
||||
@ -33,6 +33,7 @@ impl From<&Error> for WarningMessage {
|
||||
}
|
||||
},
|
||||
Error::Unexpected(_) => WarningMessage("Unknown error".to_string()),
|
||||
Error::HardwareWallet(_) => WarningMessage("Hardware wallet error".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,13 +1,18 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::Debug;
|
||||
|
||||
use log::{error, info};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
|
||||
pub mod error;
|
||||
pub mod jsonrpc;
|
||||
|
||||
use minisafe::config::Config;
|
||||
use minisafe::{
|
||||
config::Config,
|
||||
miniscript::bitcoin::{consensus, util::psbt::Psbt, Address, OutPoint, Txid},
|
||||
};
|
||||
|
||||
use super::{model::*, Daemon, DaemonError};
|
||||
|
||||
@ -70,6 +75,44 @@ impl<C: Client + Debug> Daemon for Minisafed<C> {
|
||||
fn list_coins(&self) -> Result<ListCoinsResult, DaemonError> {
|
||||
self.call("listcoins", Option::<Request>::None)
|
||||
}
|
||||
|
||||
fn list_spend_txs(&self) -> Result<ListSpendResult, DaemonError> {
|
||||
self.call("listspend", Option::<Request>::None)
|
||||
}
|
||||
|
||||
fn create_spend_tx(
|
||||
&self,
|
||||
coins_outpoints: &[OutPoint],
|
||||
destinations: &HashMap<Address, u64>,
|
||||
feerate_vb: u64,
|
||||
) -> Result<CreateSpendResult, DaemonError> {
|
||||
self.call(
|
||||
"createspend",
|
||||
Some(vec![
|
||||
json!(coins_outpoints),
|
||||
json!(destinations),
|
||||
json!(feerate_vb),
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
fn update_spend_tx(&self, psbt: &Psbt) -> Result<(), DaemonError> {
|
||||
let spend_tx = base64::encode(&consensus::serialize(psbt));
|
||||
let _res: serde_json::value::Value = self.call("updatespend", Some(vec![spend_tx]))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete_spend_tx(&self, txid: &Txid) -> Result<(), DaemonError> {
|
||||
let _res: serde_json::value::Value =
|
||||
self.call("deletespend", Some(vec![txid.to_string()]))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn broadcast_spend_tx(&self, txid: &Txid) -> Result<(), DaemonError> {
|
||||
let _res: serde_json::value::Value =
|
||||
self.call("broadcastspend", Some(vec![txid.to_string()]))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
|
||||
@ -1,7 +1,12 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use super::{model::*, Daemon, DaemonError};
|
||||
use minisafe::{config::Config, DaemonHandle};
|
||||
use minisafe::{
|
||||
config::Config,
|
||||
miniscript::bitcoin::{util::psbt::Psbt, Address, OutPoint, Txid},
|
||||
DaemonHandle,
|
||||
};
|
||||
|
||||
pub struct EmbeddedDaemon {
|
||||
config: Config,
|
||||
@ -91,4 +96,64 @@ impl Daemon for EmbeddedDaemon {
|
||||
.control
|
||||
.list_coins())
|
||||
}
|
||||
|
||||
fn list_spend_txs(&self) -> Result<ListSpendResult, DaemonError> {
|
||||
Ok(self
|
||||
.handle
|
||||
.as_ref()
|
||||
.ok_or(DaemonError::NoAnswer)?
|
||||
.lock()
|
||||
.unwrap()
|
||||
.control
|
||||
.list_spend())
|
||||
}
|
||||
|
||||
fn create_spend_tx(
|
||||
&self,
|
||||
coins_outpoints: &[OutPoint],
|
||||
destinations: &HashMap<Address, u64>,
|
||||
feerate_vb: u64,
|
||||
) -> Result<CreateSpendResult, DaemonError> {
|
||||
self.handle
|
||||
.as_ref()
|
||||
.ok_or(DaemonError::NoAnswer)?
|
||||
.lock()
|
||||
.unwrap()
|
||||
.control
|
||||
.create_spend(coins_outpoints, destinations, feerate_vb)
|
||||
.map_err(|e| DaemonError::Unexpected(e.to_string()))
|
||||
}
|
||||
|
||||
fn update_spend_tx(&self, psbt: &Psbt) -> Result<(), DaemonError> {
|
||||
self.handle
|
||||
.as_ref()
|
||||
.ok_or(DaemonError::NoAnswer)?
|
||||
.lock()
|
||||
.unwrap()
|
||||
.control
|
||||
.update_spend(psbt.clone())
|
||||
.map_err(|e| DaemonError::Unexpected(e.to_string()))
|
||||
}
|
||||
|
||||
fn delete_spend_tx(&self, txid: &Txid) -> Result<(), DaemonError> {
|
||||
self.handle
|
||||
.as_ref()
|
||||
.ok_or(DaemonError::NoAnswer)?
|
||||
.lock()
|
||||
.unwrap()
|
||||
.control
|
||||
.delete_spend(txid);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn broadcast_spend_tx(&self, txid: &Txid) -> Result<(), DaemonError> {
|
||||
self.handle
|
||||
.as_ref()
|
||||
.ok_or(DaemonError::NoAnswer)?
|
||||
.lock()
|
||||
.unwrap()
|
||||
.control
|
||||
.broadcast_spend(txid)
|
||||
.map_err(|e| DaemonError::Unexpected(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,10 +2,14 @@ pub mod client;
|
||||
pub mod embedded;
|
||||
pub mod model;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::Debug;
|
||||
use std::io::ErrorKind;
|
||||
|
||||
use minisafe::config::Config;
|
||||
use minisafe::{
|
||||
config::Config,
|
||||
miniscript::bitcoin::{util::psbt::Psbt, Address, OutPoint, Txid},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum DaemonError {
|
||||
@ -49,4 +53,39 @@ pub trait Daemon: Debug {
|
||||
fn get_new_address(&self) -> Result<model::GetAddressResult, DaemonError>;
|
||||
|
||||
fn list_coins(&self) -> Result<model::ListCoinsResult, DaemonError>;
|
||||
|
||||
fn list_spend_txs(&self) -> Result<model::ListSpendResult, DaemonError>;
|
||||
|
||||
fn list_spend_transactions(&self) -> Result<Vec<model::SpendTx>, DaemonError> {
|
||||
let coins = self.list_coins()?.coins;
|
||||
let spend_txs = self.list_spend_txs()?.spend_txs;
|
||||
Ok(spend_txs
|
||||
.into_iter()
|
||||
.map(|tx| {
|
||||
let coins = coins
|
||||
.iter()
|
||||
.filter(|coin| {
|
||||
tx.psbt
|
||||
.unsigned_tx
|
||||
.input
|
||||
.iter()
|
||||
.any(|input| input.previous_output == coin.outpoint)
|
||||
})
|
||||
.copied()
|
||||
.collect();
|
||||
model::SpendTx::new(tx.psbt, tx.change_index.map(|i| i as usize), coins)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn create_spend_tx(
|
||||
&self,
|
||||
coins_outpoints: &[OutPoint],
|
||||
destinations: &HashMap<Address, u64>,
|
||||
feerate_vb: u64,
|
||||
) -> Result<model::CreateSpendResult, DaemonError>;
|
||||
|
||||
fn update_spend_tx(&self, psbt: &Psbt) -> Result<(), DaemonError>;
|
||||
fn delete_spend_tx(&self, txid: &Txid) -> Result<(), DaemonError>;
|
||||
fn broadcast_spend_tx(&self, txid: &Txid) -> Result<(), DaemonError>;
|
||||
}
|
||||
|
||||
@ -1,3 +1,63 @@
|
||||
pub use minisafe::commands::{GetAddressResult, GetInfoResult, ListCoinsEntry, ListCoinsResult};
|
||||
pub use minisafe::{
|
||||
commands::{
|
||||
CreateSpendResult, GetAddressResult, GetInfoResult, ListCoinsEntry, ListCoinsResult,
|
||||
ListSpendEntry, ListSpendResult,
|
||||
},
|
||||
miniscript::bitcoin::{util::psbt::Psbt, Amount},
|
||||
};
|
||||
|
||||
pub type Coin = ListCoinsEntry;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SpendTx {
|
||||
pub coins: Vec<Coin>,
|
||||
pub psbt: Psbt,
|
||||
pub change_index: Option<usize>,
|
||||
pub spend_amount: Amount,
|
||||
pub fee_amount: Amount,
|
||||
pub status: SpendStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SpendStatus {
|
||||
Pending,
|
||||
Deprecated,
|
||||
Broadcasted,
|
||||
}
|
||||
|
||||
impl SpendTx {
|
||||
pub fn new(psbt: Psbt, change_index: Option<usize>, coins: Vec<Coin>) -> Self {
|
||||
let (change_amount, spend_amount) = psbt.unsigned_tx.output.iter().enumerate().fold(
|
||||
(Amount::from_sat(0), Amount::from_sat(0)),
|
||||
|(change, spend), (i, output)| {
|
||||
if Some(i) == change_index {
|
||||
(change + Amount::from_sat(output.value), spend)
|
||||
} else {
|
||||
(change, spend + Amount::from_sat(output.value))
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
let mut inputs_amount = Amount::from_sat(0);
|
||||
let mut status = SpendStatus::Pending;
|
||||
for coin in &coins {
|
||||
inputs_amount += coin.amount;
|
||||
if let Some(info) = coin.spend_info {
|
||||
if info.txid == psbt.unsigned_tx.txid() {
|
||||
status = SpendStatus::Broadcasted
|
||||
} else {
|
||||
status = SpendStatus::Deprecated
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
coins,
|
||||
psbt,
|
||||
change_index,
|
||||
spend_amount,
|
||||
fee_amount: inputs_amount - spend_amount - change_amount,
|
||||
status,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -42,7 +42,12 @@ enum Step {
|
||||
pub enum Message {
|
||||
Event(iced_native::Event),
|
||||
Syncing(Result<GetInfoResult, DaemonError>),
|
||||
Synced(GetInfoResult, Vec<Coin>, Arc<dyn Daemon + Sync + Send>),
|
||||
Synced(
|
||||
GetInfoResult,
|
||||
Vec<Coin>,
|
||||
Vec<SpendTx>,
|
||||
Arc<dyn Daemon + Sync + Send>,
|
||||
),
|
||||
Started(Result<Arc<dyn Daemon + Sync + Send>, Error>),
|
||||
Loaded(Result<Arc<dyn Daemon + Sync + Send>, Error>),
|
||||
Failure(DaemonError),
|
||||
@ -127,9 +132,12 @@ impl Loader {
|
||||
.list_coins()
|
||||
.map(|res| res.coins)
|
||||
.unwrap_or_else(|_| Vec::new());
|
||||
(info, coins, daemon)
|
||||
let spend_txs = daemon
|
||||
.list_spend_transactions()
|
||||
.unwrap_or_else(|_| Vec::new());
|
||||
(info, coins, spend_txs, daemon)
|
||||
},
|
||||
|res| Message::Synced(res.0, res.1, res.2),
|
||||
|res| Message::Synced(res.0, res.1, res.2, res.3),
|
||||
);
|
||||
} else {
|
||||
*progress = info.sync
|
||||
|
||||
@ -161,10 +161,12 @@ impl Application for GUI {
|
||||
}
|
||||
}
|
||||
(State::Loader(loader), Message::Load(msg)) => {
|
||||
if let loader::Message::Synced(info, coins, minisafed) = *msg {
|
||||
if let loader::Message::Synced(info, coins, spend_txs, minisafed) = *msg {
|
||||
let cache = Cache {
|
||||
network: minisafed.config().bitcoin_config.network,
|
||||
blockheight: info.blockheight,
|
||||
coins,
|
||||
spend_txs,
|
||||
};
|
||||
|
||||
let (app, command) = App::new(cache, loader.gui_config.clone(), minisafed);
|
||||
|
||||
@ -117,3 +117,41 @@ pub fn coin<T>() -> widget::container::Container<'static, T> {
|
||||
.center_x()
|
||||
.center_y()
|
||||
}
|
||||
|
||||
pub enum PillStyle {
|
||||
InversePrimary,
|
||||
Primary,
|
||||
Success,
|
||||
Simple,
|
||||
}
|
||||
|
||||
impl widget::container::StyleSheet for PillStyle {
|
||||
fn style(&self) -> widget::container::Style {
|
||||
match self {
|
||||
Self::Primary => widget::container::Style {
|
||||
background: color::PRIMARY.into(),
|
||||
border_radius: 10.0,
|
||||
text_color: iced::Color::WHITE.into(),
|
||||
..widget::container::Style::default()
|
||||
},
|
||||
Self::InversePrimary => widget::container::Style {
|
||||
background: color::FOREGROUND.into(),
|
||||
border_radius: 10.0,
|
||||
text_color: color::PRIMARY.into(),
|
||||
..widget::container::Style::default()
|
||||
},
|
||||
Self::Success => widget::container::Style {
|
||||
background: color::SUCCESS.into(),
|
||||
border_radius: 10.0,
|
||||
text_color: iced::Color::WHITE.into(),
|
||||
..widget::container::Style::default()
|
||||
},
|
||||
Self::Simple => widget::container::Style {
|
||||
background: color::BACKGROUND.into(),
|
||||
border_radius: 10.0,
|
||||
text_color: iced::Color::BLACK.into(),
|
||||
..widget::container::Style::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,6 +7,10 @@ use iced::{Alignment, Color, Length, Vector};
|
||||
use super::text::text;
|
||||
use crate::ui::color;
|
||||
|
||||
pub fn alert<'a, T: 'a>(icon: Option<iced::Text>, t: &str) -> button::Button<'a, T> {
|
||||
button::Button::new(content(icon, t)).style(Style::Destructive)
|
||||
}
|
||||
|
||||
pub fn primary<'a, T: 'a>(icon: Option<iced::Text>, t: &str) -> button::Button<'a, T> {
|
||||
button::Button::new(content(icon, t)).style(Style::Primary)
|
||||
}
|
||||
@ -41,6 +45,8 @@ pub enum Style {
|
||||
Primary,
|
||||
Transparent,
|
||||
TransparentBorder,
|
||||
Border,
|
||||
Destructive,
|
||||
}
|
||||
|
||||
impl button::StyleSheet for Style {
|
||||
@ -54,6 +60,14 @@ impl button::StyleSheet for Style {
|
||||
border_color: Color::TRANSPARENT,
|
||||
text_color: color::FOREGROUND,
|
||||
},
|
||||
Style::Destructive => button::Style {
|
||||
shadow_offset: Vector::default(),
|
||||
background: color::FOREGROUND.into(),
|
||||
border_radius: 10.0,
|
||||
border_width: 0.0,
|
||||
border_color: color::ALERT,
|
||||
text_color: color::ALERT,
|
||||
},
|
||||
Style::Transparent | Style::TransparentBorder => button::Style {
|
||||
shadow_offset: Vector::default(),
|
||||
background: Color::TRANSPARENT.into(),
|
||||
@ -62,6 +76,14 @@ impl button::StyleSheet for Style {
|
||||
border_color: Color::TRANSPARENT,
|
||||
text_color: Color::BLACK,
|
||||
},
|
||||
Style::Border => button::Style {
|
||||
shadow_offset: Vector::default(),
|
||||
background: Color::TRANSPARENT.into(),
|
||||
border_radius: 10.0,
|
||||
border_width: 1.2,
|
||||
border_color: color::BACKGROUND,
|
||||
text_color: Color::BLACK,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -75,6 +97,14 @@ impl button::StyleSheet for Style {
|
||||
border_color: Color::TRANSPARENT,
|
||||
text_color: color::FOREGROUND,
|
||||
},
|
||||
Style::Destructive => button::Style {
|
||||
shadow_offset: Vector::default(),
|
||||
background: color::FOREGROUND.into(),
|
||||
border_radius: 10.0,
|
||||
border_width: 0.0,
|
||||
border_color: color::ALERT,
|
||||
text_color: color::ALERT,
|
||||
},
|
||||
Style::Transparent => button::Style {
|
||||
shadow_offset: Vector::default(),
|
||||
background: color::BACKGROUND.into(),
|
||||
@ -91,6 +121,14 @@ impl button::StyleSheet for Style {
|
||||
border_color: Color::BLACK,
|
||||
text_color: Color::BLACK,
|
||||
},
|
||||
Style::Border => button::Style {
|
||||
shadow_offset: Vector::default(),
|
||||
background: Color::TRANSPARENT.into(),
|
||||
border_radius: 10.0,
|
||||
border_width: 1.0,
|
||||
border_color: Color::BLACK,
|
||||
text_color: Color::BLACK,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,6 +17,32 @@ impl widget::container::StyleSheet for SimpleCardStyle {
|
||||
}
|
||||
}
|
||||
|
||||
/// display an error card with the message and the error in a tooltip.
|
||||
pub fn warning<'a, T: 'a>(message: &str) -> widget::Container<'a, T> {
|
||||
container(
|
||||
row()
|
||||
.spacing(20)
|
||||
.align_items(iced::Alignment::Center)
|
||||
.push(icon::warning_octagon_icon().color(color::WARNING))
|
||||
.push(text(message).color(color::WARNING)),
|
||||
)
|
||||
.padding(15)
|
||||
.style(WarningCardStyle)
|
||||
}
|
||||
|
||||
pub struct WarningCardStyle;
|
||||
impl widget::container::StyleSheet for WarningCardStyle {
|
||||
fn style(&self) -> widget::container::Style {
|
||||
widget::container::Style {
|
||||
border_radius: 10.0,
|
||||
border_color: color::WARNING,
|
||||
border_width: 1.5,
|
||||
background: color::FOREGROUND.into(),
|
||||
..widget::container::Style::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// display an error card with the message and the error in a tooltip.
|
||||
pub fn error<'a, T: 'a>(message: &str, error: &str) -> widget::Container<'a, T> {
|
||||
container(
|
||||
|
||||
@ -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