gui: new transactions panel

close #437
This commit is contained in:
edouard 2023-04-19 11:50:24 +02:00
parent 5d028b03f7
commit 196b8cc3e9
8 changed files with 404 additions and 78 deletions

View File

@ -3,6 +3,7 @@ pub enum Menu {
Home,
Receive,
PSBTs,
Transactions,
Settings,
Coins,
CreateSpendTx,

View File

@ -24,7 +24,10 @@ use liana_ui::widget::Element;
pub use config::Config;
pub use message::Message;
use state::{CoinsPanel, CreateSpendPanel, Home, PsbtsPanel, ReceivePanel, RecoveryPanel, State};
use state::{
CoinsPanel, CreateSpendPanel, Home, PsbtsPanel, ReceivePanel, RecoveryPanel, State,
TransactionsPanel,
};
use crate::{
app::{cache::Cache, error::Error, menu::Menu, wallet::Wallet},
@ -81,6 +84,7 @@ impl App {
)
.into(),
menu::Menu::Receive => ReceivePanel::default().into(),
menu::Menu::Transactions => TransactionsPanel::new().into(),
menu::Menu::PSBTs => PsbtsPanel::new(self.wallet.clone(), &self.cache.spend_txs).into(),
menu::Menu::CreateSpendTx => CreateSpendPanel::new(
self.wallet.clone(),

View File

@ -3,6 +3,7 @@ mod psbts;
mod recovery;
mod settings;
mod spend;
mod transactions;
use std::convert::TryInto;
use std::sync::Arc;
@ -23,6 +24,7 @@ pub use psbts::PsbtsPanel;
pub use recovery::RecoveryPanel;
pub use settings::SettingsState;
pub use spend::CreateSpendPanel;
pub use transactions::TransactionsPanel;
pub trait State {
fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message>;
@ -91,7 +93,7 @@ impl State for Home {
return view::modal(
false,
self.warning.as_ref(),
view::home::event_view(cache, event),
view::transactions::tx_view(cache, event),
None::<Element<view::Message>>,
);
}

View File

@ -0,0 +1,171 @@
use std::convert::TryInto;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use iced::Command;
use liana_ui::widget::*;
use crate::app::{cache::Cache, error::Error, menu::Menu, message::Message, view, State};
use crate::daemon::{model::HistoryTransaction, Daemon};
#[derive(Default)]
pub struct TransactionsPanel {
pending_txs: Vec<HistoryTransaction>,
txs: Vec<HistoryTransaction>,
selected_tx: Option<usize>,
warning: Option<Error>,
}
impl TransactionsPanel {
pub fn new() -> Self {
Self {
selected_tx: None,
txs: Vec::new(),
pending_txs: Vec::new(),
warning: None,
}
}
}
impl State for TransactionsPanel {
fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> {
if let Some(i) = self.selected_tx {
let tx = if i < self.pending_txs.len() {
&self.pending_txs[i]
} else {
&self.txs[i - self.pending_txs.len()]
};
return view::modal(
false,
self.warning.as_ref(),
view::transactions::tx_view(cache, tx),
None::<Element<view::Message>>,
);
}
view::dashboard(
&Menu::Transactions,
cache,
None,
view::transactions::transactions_view(&self.pending_txs, &self.txs),
)
}
fn update(
&mut self,
daemon: Arc<dyn Daemon + Sync + Send>,
_cache: &Cache,
message: Message,
) -> Command<Message> {
match message {
Message::HistoryTransactions(res) => match res {
Err(e) => self.warning = Some(e),
Ok(txs) => {
self.warning = None;
for tx in txs {
if !self.txs.iter().any(|other| other.tx == tx.tx) {
self.txs.push(tx);
}
}
}
},
Message::PendingTransactions(res) => match res {
Err(e) => self.warning = Some(e),
Ok(txs) => {
self.warning = None;
for tx in txs {
if !self.pending_txs.iter().any(|other| other.tx == tx.tx) {
self.pending_txs.push(tx);
}
}
}
},
Message::View(view::Message::Close) => {
self.selected_tx = None;
}
Message::View(view::Message::Select(i)) => {
self.selected_tx = Some(i);
}
Message::View(view::Message::Next) => {
if let Some(last) = self.txs.last() {
let daemon = daemon.clone();
let last_tx_date = last.time.unwrap();
return Command::perform(
async move {
let mut limit = view::home::HISTORY_EVENT_PAGE_SIZE;
let mut txs = daemon.list_history_txs(0_u32, last_tx_date, limit)?;
// because gethistory cursor is inclusive and use blocktime
// multiple txs can occur in the same block.
// If there is more tx in the same block that the
// HISTORY_EVENT_PAGE_SIZE they can not be retrieved by changing
// the cursor value (blocktime) but by increasing the limit.
//
// 1. Check if the txs retrieved have all the same blocktime
let blocktime = if let Some(tx) = txs.first() {
tx.time
} else {
return Ok(txs);
};
// 2. Retrieve a larger batch of tx with the same cursor but
// a larger limit.
while !txs.iter().any(|evt| evt.time != blocktime)
&& txs.len() as u64 == limit
{
// increments of the equivalent of one page more.
limit += view::home::HISTORY_EVENT_PAGE_SIZE;
txs = daemon.list_history_txs(0, last_tx_date, limit)?;
}
Ok(txs)
},
Message::HistoryTransactions,
);
}
}
_ => {}
};
Command::none()
}
fn load(&self, daemon: Arc<dyn Daemon + Sync + Send>) -> Command<Message> {
let daemon1 = daemon.clone();
let daemon2 = daemon.clone();
let daemon3 = daemon.clone();
let now: u32 = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
.try_into()
.unwrap();
Command::batch(vec![
Command::perform(
async move { daemon3.list_pending_txs().map_err(|e| e.into()) },
Message::PendingTransactions,
),
Command::perform(
async move {
daemon1
.list_history_txs(0, now, view::home::HISTORY_EVENT_PAGE_SIZE)
.map_err(|e| e.into())
},
Message::HistoryTransactions,
),
Command::perform(
async move {
daemon2
.list_coins()
.map(|res| res.coins)
.map_err(|e| e.into())
},
Message::Coins,
),
])
}
}
impl From<TransactionsPanel> for Box<dyn State> {
fn from(s: TransactionsPanel) -> Box<dyn State> {
Box::new(s)
}
}

View File

@ -5,17 +5,14 @@ use iced::{alignment, Alignment, Length};
use liana::miniscript::bitcoin;
use liana_ui::{
color,
component::{badge, card, text::*},
component::{badge, text::*},
icon, theme,
util::Collection,
widget::*,
};
use crate::{
app::{
cache::Cache,
view::{message::Message, util::*},
},
app::view::{message::Message, util::*},
daemon::model::HistoryTransaction,
};
@ -158,74 +155,3 @@ fn event_list_view<'a>(i: usize, event: &HistoryTransaction) -> Element<'a, Mess
.style(theme::Container::Card(theme::Card::Simple))
.into()
}
pub fn event_view<'a>(cache: &Cache, event: &'a HistoryTransaction) -> Element<'a, Message> {
Column::new()
.push(
Row::new()
.push(if event.is_external() {
badge::receive()
} else {
badge::spend()
})
.spacing(10)
.align_items(Alignment::Center),
)
.push(if event.is_external() {
amount_with_size(&event.incoming_amount, 50)
} else {
amount_with_size(&event.outgoing_amount, 50)
})
.push_maybe(
event
.fee_amount
.map(|fee| Row::new().push(text("Miner Fee: ")).push(amount(&fee))),
)
.push(card::simple(
Column::new()
.push_maybe(event.time.map(|t| {
let date = NaiveDateTime::from_timestamp_opt(t as i64, 0).unwrap();
Row::new()
.width(Length::Fill)
.push(Container::new(text("Date:").bold()).width(Length::Fill))
.push(Container::new(text(format!("{}", date))).width(Length::Shrink))
}))
.push(
Row::new()
.width(Length::Fill)
.align_items(Alignment::Center)
.push(Container::new(text("Txid:").bold()).width(Length::Fill))
.push(
Row::new()
.align_items(Alignment::Center)
.push(Container::new(text(format!("{}", event.tx.txid())).small()))
.push(
Button::new(icon::clipboard_icon())
.on_press(Message::Clipboard(event.tx.txid().to_string()))
.style(theme::Button::TransparentBorder),
)
.width(Length::Shrink),
),
)
.spacing(5),
))
.push(super::spend::detail::inputs_and_outputs_view(
&event.coins,
&event.tx,
cache.network,
if event.is_external() {
None
} else {
Some(event.change_indexes.clone())
},
if event.is_external() {
Some(event.change_indexes.clone())
} else {
None
},
))
.align_items(Alignment::Center)
.spacing(20)
.max_width(800)
.into()
}

View File

@ -10,6 +10,7 @@ pub mod receive;
pub mod recovery;
pub mod settings;
pub mod spend;
pub mod transactions;
pub use message::*;
use warning::warn;
@ -41,6 +42,34 @@ pub fn sidebar<'a>(menu: &Menu, cache: &'a Cache) -> Container<'a, Message> {
.width(iced::Length::Fill)
};
let transactions_button = if *menu == Menu::Transactions {
Button::new(
row!(
history_icon().width(Length::Units(20)),
text("Transactions")
)
.spacing(10)
.padding(10)
.align_items(iced::Alignment::Center),
)
.style(theme::Button::Menu(true))
.on_press(Message::Reload)
.width(iced::Length::Fill)
} else {
Button::new(
row!(
history_icon().width(Length::Units(20)),
text("Transactions")
)
.spacing(10)
.padding(10)
.align_items(iced::Alignment::Center),
)
.style(theme::Button::Menu(false))
.on_press(Message::Menu(Menu::Transactions))
.width(iced::Length::Fill)
};
let coins_button = if *menu == Menu::Coins {
Button::new(
Container::new(
@ -266,6 +295,7 @@ pub fn sidebar<'a>(menu: &Menu, cache: &'a Cache) -> Container<'a, Message> {
.push(receive_button)
.push(coins_button)
.push(psbt_button)
.push(transactions_button)
.spacing(15)
.height(Length::Fill),
)

View File

@ -63,6 +63,7 @@ pub fn psbts_view<'a>(spend_txs: &[SpendTx]) -> Element<'a, Message> {
Column::new()
.push(
Row::new()
.align_items(Alignment::Center)
.spacing(10)
.push(Container::new(h3("PSBTs")).width(Length::Fill))
.push(

View File

@ -0,0 +1,191 @@
use chrono::NaiveDateTime;
use iced::{alignment, Alignment, Length};
use liana_ui::{
component::{badge, card, text::*},
icon, theme,
util::Collection,
widget::*,
};
use crate::{
app::{
cache::Cache,
view::{message::Message, util::*},
},
daemon::model::HistoryTransaction,
};
pub const HISTORY_EVENT_PAGE_SIZE: u64 = 20;
pub fn transactions_view<'a>(
pending_txs: &[HistoryTransaction],
txs: &Vec<HistoryTransaction>,
) -> Element<'a, Message> {
Column::new()
.push(Container::new(h3("Transactions")).width(Length::Fill))
.push(
Column::new()
.spacing(10)
.push(
pending_txs
.iter()
.enumerate()
.fold(Column::new().spacing(10), |col, (i, tx)| {
col.push(tx_list_view(i, tx))
}),
)
.push(
txs.iter()
.enumerate()
.fold(Column::new().spacing(10), |col, (i, tx)| {
col.push(tx_list_view(i + pending_txs.len(), tx))
}),
)
.push_maybe(
if txs.len() % HISTORY_EVENT_PAGE_SIZE as usize == 0 && !txs.is_empty() {
Some(
Container::new(
Button::new(
text("See more")
.width(Length::Fill)
.horizontal_alignment(alignment::Horizontal::Center),
)
.width(Length::Fill)
.padding(15)
.style(theme::Button::TransparentBorder)
.on_press(Message::Next),
)
.width(Length::Fill)
.style(theme::Container::Card(theme::Card::Simple)),
)
} else {
None
},
),
)
.align_items(Alignment::Center)
.spacing(20)
.into()
}
fn tx_list_view<'a>(i: usize, tx: &HistoryTransaction) -> Element<'a, Message> {
Container::new(
Button::new(
Row::new()
.push(
Row::new()
.push(if tx.is_external() {
badge::receive()
} else {
badge::spend()
})
.push(if let Some(t) = tx.time {
Container::new(
text(format!(
"{}",
NaiveDateTime::from_timestamp_opt(t as i64, 0).unwrap(),
))
.small(),
)
} else {
badge::unconfirmed()
})
.spacing(10)
.align_items(Alignment::Center)
.width(Length::Fill),
)
.push(if tx.is_external() {
Row::new()
.spacing(5)
.push(text("+"))
.push(amount(&tx.incoming_amount))
.align_items(Alignment::Center)
} else {
Row::new()
.spacing(5)
.push(text("-"))
.push(amount(&tx.outgoing_amount))
.align_items(Alignment::Center)
})
.align_items(Alignment::Center)
.spacing(20),
)
.padding(10)
.on_press(Message::Select(i))
.style(theme::Button::TransparentBorder),
)
.style(theme::Container::Card(theme::Card::Simple))
.into()
}
pub fn tx_view<'a>(cache: &Cache, tx: &'a HistoryTransaction) -> Element<'a, Message> {
Column::new()
.push(
Row::new()
.push(if tx.is_external() {
badge::receive()
} else {
badge::spend()
})
.spacing(10)
.align_items(Alignment::Center),
)
.push(if tx.is_external() {
amount_with_size(&tx.incoming_amount, 50)
} else {
amount_with_size(&tx.outgoing_amount, 50)
})
.push_maybe(
tx.fee_amount
.map(|fee| Row::new().push(text("Miner Fee: ")).push(amount(&fee))),
)
.push(card::simple(
Column::new()
.push_maybe(tx.time.map(|t| {
let date = NaiveDateTime::from_timestamp_opt(t as i64, 0).unwrap();
Row::new()
.width(Length::Fill)
.push(Container::new(text("Date:").bold()).width(Length::Fill))
.push(Container::new(text(format!("{}", date))).width(Length::Shrink))
}))
.push(
Row::new()
.width(Length::Fill)
.align_items(Alignment::Center)
.push(Container::new(text("Txid:").bold()).width(Length::Fill))
.push(
Row::new()
.align_items(Alignment::Center)
.push(Container::new(text(format!("{}", tx.tx.txid())).small()))
.push(
Button::new(icon::clipboard_icon())
.on_press(Message::Clipboard(tx.tx.txid().to_string()))
.style(theme::Button::TransparentBorder),
)
.width(Length::Shrink),
),
)
.spacing(5),
))
.push(super::spend::detail::inputs_and_outputs_view(
&tx.coins,
&tx.tx,
cache.network,
if tx.is_external() {
None
} else {
Some(tx.change_indexes.clone())
},
if tx.is_external() {
Some(tx.change_indexes.clone())
} else {
None
},
))
.align_items(Alignment::Center)
.spacing(20)
.max_width(800)
.into()
}