From 4a8c9b4d1a7f386ec5e9bd442abaef2ea02213e4 Mon Sep 17 00:00:00 2001 From: mertwole <33563701+mertwole@users.noreply.github.com> Date: Thu, 22 Aug 2024 00:37:58 +0700 Subject: [PATCH] feat(asset screen): Add placeholders and pretty-print addresses (#37) --- Cargo.lock | 1 + app/Cargo.toml | 3 +- app/src/api/blockchain_monitoring.rs | 2 +- app/src/api/cache_utils.rs | 21 +++ app/src/api/{common.rs => common_types.rs} | 0 app/src/api/ledger.rs | 2 +- app/src/api/mod.rs | 2 +- app/src/app.rs | 9 +- app/src/screen/asset/mod.rs | 2 +- app/src/screen/asset/view.rs | 157 ++++++++++++------ app/src/screen/common.rs | 60 ++++++- app/src/screen/device_selection/controller.rs | 6 +- app/src/screen/portfolio/mod.rs | 2 +- app/src/screen/portfolio/view.rs | 2 +- 14 files changed, 202 insertions(+), 67 deletions(-) rename app/src/api/{common.rs => common_types.rs} (100%) diff --git a/Cargo.lock b/Cargo.lock index 9763d4d..9f498f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1443,6 +1443,7 @@ dependencies = [ "chrono", "copypasta", "futures", + "itertools 0.13.0", "ledger-lib", "ledger-proto", "ledger_bitcoin_client", diff --git a/app/Cargo.toml b/app/Cargo.toml index 9af4232..35c675e 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -12,6 +12,7 @@ bs58.workspace = true chrono = { workspace = true, features = ["serde"] } copypasta.workspace = true futures.workspace = true +itertools.workspace = true ledger_bitcoin_client.workspace = true ledger-lib.workspace = true ledger-proto.workspace = true @@ -25,5 +26,5 @@ rust_decimal_macros.workspace = true serde.workspace = true serde_json.workspace = true strum = { workspace = true, features = ["derive"] } -tokio.workspace = true +tokio = { workspace = true, features = ["time"] } tui-widget-list.workspace = true diff --git a/app/src/api/blockchain_monitoring.rs b/app/src/api/blockchain_monitoring.rs index 820e0cf..b278b79 100644 --- a/app/src/api/blockchain_monitoring.rs +++ b/app/src/api/blockchain_monitoring.rs @@ -4,7 +4,7 @@ use api_proc_macro::implement_cache; use chrono::{DateTime, Utc}; use rust_decimal::Decimal; -use super::common::{Account, Network}; +use super::common_types::{Account, Network}; // TODO: This API will be fallible (return `Result<...>`) in future. implement_cache! { diff --git a/app/src/api/cache_utils.rs b/app/src/api/cache_utils.rs index 46ca7df..a378c3c 100644 --- a/app/src/api/cache_utils.rs +++ b/app/src/api/cache_utils.rs @@ -5,12 +5,14 @@ use std::{ pin::Pin, time::{Duration, Instant}, }; +use tokio::time::sleep; #[derive(Default, Clone, Copy)] pub enum ModePlan { #[default] Transparent, TimedOut(Duration), + Slow(Duration), } impl ModePlan { @@ -18,6 +20,7 @@ impl ModePlan { match self { Self::Transparent => Mode::new_transparent(), Self::TimedOut(timeout) => Mode::new_timed_out(timeout), + Self::Slow(delay) => Mode::new_slow(delay), } } } @@ -30,6 +33,8 @@ pub enum Mode { /// This type of cache will call API only if some specified time have passed after previous call. /// It will return value from cache elsewhere. TimedOut(TimedOutMode), + /// This type of cache will delay calls to API to simulate network or i/o delays. + Slow(Duration), } #[derive(Clone)] @@ -49,6 +54,10 @@ impl Mode { previous_request: Default::default(), }) } + + pub fn new_slow(delay: Duration) -> Self { + Self::Slow(delay) + } } pub(super) async fn use_cache( @@ -64,6 +73,7 @@ where match mode { Mode::Transparent => transparent_mode(request, cache, api_result).await, Mode::TimedOut(state) => timed_out_mode(request, cache, api_result, state).await, + Mode::Slow(delay) => slow_mode(request, cache, api_result, *delay).await, } } @@ -101,3 +111,14 @@ where result } + +async fn slow_mode( + _request: In, + _cache: Entry<'_, In, Out>, + api_result: Pin>>, + delay: Duration, +) -> Out { + sleep(delay).await; + + api_result.await +} diff --git a/app/src/api/common.rs b/app/src/api/common_types.rs similarity index 100% rename from app/src/api/common.rs rename to app/src/api/common_types.rs diff --git a/app/src/api/ledger.rs b/app/src/api/ledger.rs index ec6cdab..bf5ebea 100644 --- a/app/src/api/ledger.rs +++ b/app/src/api/ledger.rs @@ -7,7 +7,7 @@ use ledger_lib::{ DEFAULT_TIMEOUT, }; -use super::common::{Account, Network}; +use super::common_types::{Account, Network}; implement_cache!( pub trait LedgerApiT { diff --git a/app/src/api/mod.rs b/app/src/api/mod.rs index 41969e4..f6b10db 100644 --- a/app/src/api/mod.rs +++ b/app/src/api/mod.rs @@ -1,5 +1,5 @@ pub mod blockchain_monitoring; pub mod cache_utils; pub mod coin_price; -pub mod common; +pub mod common_types; pub mod ledger; diff --git a/app/src/app.rs b/app/src/app.rs index 82b700a..e2ca95b 100644 --- a/app/src/app.rs +++ b/app/src/app.rs @@ -18,7 +18,7 @@ use crate::{ coin_price::{ cache::Cache as CoinPriceApiCache, mock::CoinPriceApiMock, CoinPriceApi, CoinPriceApiT, }, - common::{Account, Network}, + common_types::{Account, Network}, ledger::{ cache::Cache as LedgerApiCache, mock::LedgerApiMock, Device, DeviceInfo, LedgerApiT, }, @@ -64,7 +64,7 @@ impl StateRegistry { impl App { pub async fn new() -> Self { Self { - screens: vec![ScreenName::DeviceSelection], + screens: vec![ScreenName::Portfolio, ScreenName::DeviceSelection], } } @@ -84,12 +84,15 @@ impl App { let mut state = Some(StateRegistry::new()); let api_registry = { - let ledger_api = LedgerApiMock::new(10, 3); + let ledger_api = LedgerApiMock::new(2, 3); let mut ledger_api = block_on(LedgerApiCache::new(ledger_api)); ledger_api.set_all_modes(ModePlan::Transparent); let _coin_price_api = CoinPriceApiMock::new(); let coin_price_api = CoinPriceApi::new("https://data-api.binance.vision"); + let mut coin_price_api = block_on(CoinPriceApiCache::new(coin_price_api)); + coin_price_api.set_all_modes(ModePlan::Slow(Duration::from_secs(1))); + let mut coin_price_api = block_on(CoinPriceApiCache::new(coin_price_api)); coin_price_api.set_all_modes(ModePlan::TimedOut(Duration::from_secs(5))); diff --git a/app/src/screen/asset/mod.rs b/app/src/screen/asset/mod.rs index 86b9bfd..a745aca 100644 --- a/app/src/screen/asset/mod.rs +++ b/app/src/screen/asset/mod.rs @@ -8,7 +8,7 @@ use crate::{ api::{ blockchain_monitoring::{BlockchainMonitoringApiT, TransactionInfo, TransactionUid}, coin_price::{Coin, CoinPriceApiT, TimePeriod as ApiTimePeriod}, - common::Network, + common_types::Network, ledger::LedgerApiT, }, app::{ApiRegistry, StateRegistry}, diff --git a/app/src/screen/asset/view.rs b/app/src/screen/asset/view.rs index 9ca3d01..fd95825 100644 --- a/app/src/screen/asset/view.rs +++ b/app/src/screen/asset/view.rs @@ -8,19 +8,26 @@ use ratatui::{ }, Frame, }; +use rust_decimal::Decimal; use strum::IntoEnumIterator; use crate::{ api::{ - blockchain_monitoring::{BlockchainMonitoringApiT, TransactionType}, + blockchain_monitoring::{ + BlockchainMonitoringApiT, TransactionInfo, TransactionType, TransactionUid, + }, coin_price::CoinPriceApiT, + common_types::{Account, Network}, ledger::LedgerApiT, }, - screen::common::network_symbol, + screen::common::{format_address, network_symbol, render_centered_text}, }; use super::{Model, TimePeriod}; +const ADDRESSES_MAX_LEN: usize = 12; +const TX_UID_MAX_LEN: usize = 16; + pub(super) fn render( model: &Model, frame: &mut Frame<'_>, @@ -35,7 +42,17 @@ pub(super) fn render { + render_empty_tx_list(frame, inner_txs_list_area); + } + Some(tx_list) => { + let selected_account = model + .state + .selected_account + .as_ref() + .expect("Selected accounmodelt should be present in state"); // TODO: Enforce this rule at `app` level? + + render_tx_list( + selected_account.clone(), + &tx_list[..], + frame, + inner_txs_list_area, + ); + } + None => { + render_tx_list_placeholder(frame, inner_txs_list_area); + } + } } -fn render_price_chart( - model: &Model, +fn render_price_chart( + prices: &[Decimal], + selected_time_period: TimePeriod, frame: &mut Frame<'_>, area: Rect, ) { - let Some(prices) = model.coin_price_history.as_ref() else { - // TODO: Draw some placeholder. - return; - }; + let legend = render_chart_legend(selected_time_period); let max_price = *prices.iter().max().expect("Empty `prices` vector provided"); @@ -64,24 +101,6 @@ fn render_price_chart " d[ay]", - TimePeriod::Week => " w[eek]", - TimePeriod::Month => " m[onth]", - TimePeriod::Year => " y[ear]", - TimePeriod::All => " a[ll] ", - }; - - if period == model.selected_time_period { - Span::raw(label).red() - } else { - Span::raw(label).green() - } - }); - - let legend = Line::from_iter(legend); - let datasets = vec![Dataset::default() .marker(symbols::Marker::Bar) .name(legend) @@ -107,36 +126,60 @@ fn render_price_chart( - model: &Model, +fn render_price_chart_placeholder( + selected_time_period: TimePeriod, frame: &mut Frame<'_>, area: Rect, ) { - let Some(tx_list) = model.transactions.as_ref() else { - // TODO: Draw placeholder(fetching txs...) - return; - }; - - if tx_list.is_empty() { - // TODO: Draw placeholder(no txs yet...) - return; - } + let legend = render_chart_legend(selected_time_period); - let (selected_account_network, selected_account) = model - .state - .selected_account - .as_ref() - .expect("Selected account should be present in state"); // TODO: Enforce this rule at `app` level? + let chart = Chart::new(vec![Dataset::default().name(legend)]) + .legend_position(Some(ratatui::widgets::LegendPosition::BottomRight)) + // Always show a legend(see `hidden_legend_constraints` docs). + .hidden_legend_constraints((Constraint::Min(0), Constraint::Min(0))); + + frame.render_widget(chart, area); + + let text = Text::raw("Price is loading..."); + render_centered_text(frame, area, text); +} + +fn render_chart_legend(selected_time_period: TimePeriod) -> Line<'static> { + let legend = TimePeriod::iter().map(|period| { + let label = match period { + TimePeriod::Day => " d[ay]", + TimePeriod::Week => " w[eek]", + TimePeriod::Month => " m[onth]", + TimePeriod::Year => " y[ear]", + TimePeriod::All => " a[ll] ", + }; + + if period == selected_time_period { + Span::raw(label).red() + } else { + Span::raw(label).green() + } + }); + + Line::from_iter(legend) +} + +fn render_tx_list( + selected_account: (Network, Account), + tx_list: &[(TransactionUid, TransactionInfo)], + frame: &mut Frame<'_>, + area: Rect, +) { + let (selected_account_network, selected_account) = selected_account; let selected_account_address = selected_account.get_info().pk; - let network_icon = network_symbol(*selected_account_network); + let network_icon = network_symbol(selected_account_network); let rows = tx_list .iter() .map(|(uid, tx)| { - // TODO: Pretty-format. - let uid = &uid.uid; + let uid = format_address(&uid.uid, TX_UID_MAX_LEN); let uid = Text::raw(uid).alignment(Alignment::Center); let time = format!("{}", tx.timestamp.format("%Y-%m-%d %H:%M UTC%:z")); @@ -144,9 +187,8 @@ fn render_tx_list( let description = match &tx.ty { TransactionType::Deposit { from, amount } => { - // TODO: Pretty-format. - let from = [&from.get_info().pk[..8], "..."].concat(); - let to = [&selected_account_address[..8], "..."].concat(); + let from = format_address(&from.get_info().pk, ADDRESSES_MAX_LEN); + let to = format_address(&selected_account_address, ADDRESSES_MAX_LEN); vec![ Span::raw(from), @@ -156,9 +198,8 @@ fn render_tx_list( ] } TransactionType::Withdraw { to, amount } => { - // TODO: Pretty-format. - let from = [&selected_account_address[..8], "..."].concat(); - let to = [&to.get_info().pk[..8], "..."].concat(); + let from = format_address(&selected_account_address, ADDRESSES_MAX_LEN); + let to = format_address(&to.get_info().pk, ADDRESSES_MAX_LEN); vec![ Span::raw(from).green(), @@ -182,3 +223,13 @@ fn render_tx_list( frame.render_widget(table, area) } + +fn render_empty_tx_list(frame: &mut Frame<'_>, area: Rect) { + let text = Text::raw("No transactions here yet"); + render_centered_text(frame, area, text) +} + +fn render_tx_list_placeholder(frame: &mut Frame<'_>, area: Rect) { + let text = Text::raw("Fetching transactions..."); + render_centered_text(frame, area, text) +} diff --git a/app/src/screen/common.rs b/app/src/screen/common.rs index d1cee15..7631d28 100644 --- a/app/src/screen/common.rs +++ b/app/src/screen/common.rs @@ -1,4 +1,10 @@ -use crate::api::common::Network; +use ratatui::{ + layout::{Constraint, Flex, Layout, Rect}, + text::Text, + Frame, +}; + +use crate::api::common_types::Network; pub fn network_symbol(network: Network) -> String { match network { @@ -7,3 +13,55 @@ pub fn network_symbol(network: Network) -> String { } .to_string() } + +pub fn render_centered_text(frame: &mut Frame, area: Rect, text: Text) { + let [area] = Layout::horizontal([Constraint::Length(text.width() as u16)]) + .flex(Flex::Center) + .areas(area); + let [area] = Layout::vertical([Constraint::Length(text.height() as u16)]) + .flex(Flex::Center) + .areas(area); + + frame.render_widget(text, area); +} + +pub fn format_address(address: &str, max_symbols: usize) -> String { + if max_symbols <= 3 { + return "".to_string(); + } + + if max_symbols <= 8 { + return "...".to_string(); + } + + let part_size = (max_symbols - 3) / 2; + let part_size = part_size.min(8); + + if address.len() <= part_size * 2 { + return address.to_string(); + } + + format!( + "{}...{}", + &address[..part_size], + &address[(address.len() - part_size)..] + ) +} + +#[cfg(test)] +mod tests { + use itertools::Itertools; + + use super::format_address; + + #[test] + fn test_format_address() { + let address_lengths = [0, 2, 8, 10, 100].into_iter(); + let max_lengths = [0, 3, 5, 6, 8, 10, 100].into_iter(); + + for (addr_len, max_len) in address_lengths.cartesian_product(max_lengths) { + let address = "0".repeat(addr_len); + assert!(format_address(&address, max_len).len() <= max_len); + } + } +} diff --git a/app/src/screen/device_selection/controller.rs b/app/src/screen/device_selection/controller.rs index 0c578ed..71cad93 100644 --- a/app/src/screen/device_selection/controller.rs +++ b/app/src/screen/device_selection/controller.rs @@ -5,7 +5,7 @@ use crate::{ blockchain_monitoring::BlockchainMonitoringApiT, coin_price::CoinPriceApiT, ledger::LedgerApiT, }, - screen::{EventExt, OutgoingMessage, ScreenName}, + screen::{EventExt, OutgoingMessage}, }; use super::Model; @@ -34,8 +34,8 @@ pub(super) fn process_input