From 718ee6852980fd8f6a2e25e7f5ab5d749a6f8e61 Mon Sep 17 00:00:00 2001 From: Lukas Bielesch Date: Fri, 28 Mar 2025 13:10:36 +0100 Subject: [PATCH] feat(eckhart): confirm summary flow --- .../ui/layout_eckhart/flow/confirm_summary.rs | 226 ++++++++++++++++++ .../rust/src/ui/layout_eckhart/flow/mod.rs | 2 + .../rust/src/ui/layout_eckhart/ui_firmware.rs | 56 ++++- .../src/trezor/ui/layouts/eckhart/__init__.py | 6 +- 4 files changed, 277 insertions(+), 13 deletions(-) create mode 100644 core/embed/rust/src/ui/layout_eckhart/flow/confirm_summary.rs diff --git a/core/embed/rust/src/ui/layout_eckhart/flow/confirm_summary.rs b/core/embed/rust/src/ui/layout_eckhart/flow/confirm_summary.rs new file mode 100644 index 0000000000..e11f381ffe --- /dev/null +++ b/core/embed/rust/src/ui/layout_eckhart/flow/confirm_summary.rs @@ -0,0 +1,226 @@ +use heapless::Vec; + +use crate::{ + error::{self}, + strutil::TString, + translations::TR, + ui::{ + component::{ + text::paragraphs::{Paragraph, ParagraphSource, ParagraphVecShort, Paragraphs}, + ComponentExt, MsgMap, + }, + flow::{ + base::{Decision, DecisionBuilder as _}, + FlowController, FlowMsg, SwipeFlow, + }, + geometry::{Alignment, Direction, LinearPlacement, Offset}, + }, +}; + +use super::super::{ + component::Button, + firmware::{ + ActionBar, Header, Hint, TextScreen, TextScreenMsg, VerticalMenu, VerticalMenuScreen, + VerticalMenuScreenMsg, + }, + theme, +}; + +const MENU_ITEM_CANCEL: usize = 0; +const MENU_ITEM_EXTRA_INFO: usize = 1; +const MENU_ITEM_ACCOUNT_INFO: usize = 2; + +const TIMEOUT_MS: u32 = 2000; + +#[derive(Copy, Clone, PartialEq, Eq)] +pub enum ConfirmSummary { + Summary, + Menu, + ExtraInfo, + AccountInfo, + Cancel, + Cancelled, +} + +impl FlowController for ConfirmSummary { + #[inline] + fn index(&'static self) -> usize { + *self as usize + } + + fn handle_swipe(&'static self, _direction: Direction) -> Decision { + self.do_nothing() + } + + fn handle_event(&'static self, msg: FlowMsg) -> Decision { + match (self, msg) { + (Self::Summary, FlowMsg::Confirmed) => self.return_msg(FlowMsg::Confirmed), + (Self::Summary, FlowMsg::Info) => Self::Menu.goto(), + (Self::Menu, FlowMsg::Choice(MENU_ITEM_CANCEL)) => Self::Cancel.goto(), + (Self::Menu, FlowMsg::Choice(MENU_ITEM_EXTRA_INFO)) => Self::ExtraInfo.goto(), + (Self::Menu, FlowMsg::Choice(MENU_ITEM_ACCOUNT_INFO)) => Self::AccountInfo.goto(), + (Self::Menu, FlowMsg::Cancelled) => Self::Summary.goto(), + (_, FlowMsg::Cancelled) => Self::Menu.goto(), + (Self::Cancel, FlowMsg::Confirmed) => Self::Cancelled.goto(), + (Self::Cancelled, _) => self.return_msg(FlowMsg::Cancelled), + _ => self.do_nothing(), + } + } +} + +fn content_menu_info( + title: TString<'static>, + subtitle: Option>, + paragraphs: Option>, +) -> MsgMap< + TextScreen>>, + impl Fn(TextScreenMsg) -> Option, +> { + TextScreen::new( + paragraphs + .map_or_else(ParagraphVecShort::new, |p| p) + .into_paragraphs() + .with_placement(LinearPlacement::vertical()) + .with_spacing(12), + ) + .with_header(Header::new(title).with_close_button()) + .with_subtitle(subtitle.unwrap_or(TString::empty())) + .map(|_| Some(FlowMsg::Cancelled)) +} + +#[allow(clippy::too_many_arguments)] +pub fn new_confirm_summary( + title: TString<'static>, + amount: TString<'static>, + amount_label: TString<'static>, + fee: TString<'static>, + fee_label: TString<'static>, + account_paragraphs: Option>, + extra_title: Option>, + extra_paragraphs: Option>, + verb_cancel: Option>, +) -> Result { + // Summary + let summary_paragraphs = ParagraphVecShort::from_iter([ + Paragraph::new(&theme::TEXT_SMALL_LIGHT, amount_label), + Paragraph::new(&theme::TEXT_MONO_MEDIUM_LIGHT, amount), + Paragraph::new(&theme::TEXT_SMALL_LIGHT, fee_label), + Paragraph::new(&theme::TEXT_MONO_MEDIUM_LIGHT, fee), + ]); + + let content_summary = TextScreen::new( + summary_paragraphs + .into_paragraphs() + .with_placement(LinearPlacement::vertical()) + .with_spacing(12), + ) + .with_header(Header::new(title).with_menu_button()) + .with_action_bar(ActionBar::new_single( + Button::with_text(TR::instructions__hold_to_sign.into()) + .with_long_press(theme::CONFIRM_HOLD_DURATION) + .styled(theme::button_confirm()), + )) + .with_hint(Hint::new_instruction( + TR::send__send_in_the_app, + Some(theme::ICON_INFO), + )) + .map(|msg| match msg { + TextScreenMsg::Confirmed => Some(FlowMsg::Confirmed), + TextScreenMsg::Cancelled => Some(FlowMsg::Cancelled), + TextScreenMsg::Menu => Some(FlowMsg::Info), + }); + + // Menu + let mut menu = VerticalMenu::empty(); + let mut menu_items = Vec::::new(); + + if account_paragraphs.is_some() { + menu = menu.item( + Button::with_text(TR::address_details__account_info.into()) + .styled(theme::menu_item_title()) + .with_text_align(Alignment::Start) + .with_content_offset(Offset::x(12)), + ); + unwrap!(menu_items.push(MENU_ITEM_ACCOUNT_INFO)); + } + if extra_paragraphs.is_some() { + menu = menu.item( + Button::with_text(extra_title.unwrap_or(TR::buttons__more_info.into())) + .styled(theme::menu_item_title()) + .with_text_align(Alignment::Start) + .with_content_offset(Offset::x(12)), + ); + unwrap!(menu_items.push(MENU_ITEM_EXTRA_INFO)); + } + menu = menu.item( + Button::with_text(verb_cancel.unwrap_or(TR::buttons__cancel.into())) + .styled(theme::menu_item_title_orange()) + .with_text_align(Alignment::Start) + .with_content_offset(Offset::x(12)), + ); + unwrap!(menu_items.push(MENU_ITEM_CANCEL)); + let content_menu = VerticalMenuScreen::new(menu) + .with_header(Header::new(title).with_close_button()) + .map(move |msg| match msg { + VerticalMenuScreenMsg::Selected(i) => { + let selected_item = menu_items[i]; + Some(FlowMsg::Choice(selected_item)) + } + VerticalMenuScreenMsg::Close => Some(FlowMsg::Cancelled), + _ => None, + }); + + // ExtraInfo + let content_extra = content_menu_info( + extra_title.unwrap_or(TR::buttons__more_info.into()), + None, + extra_paragraphs, + ); + + // AccountInfo + let content_account = content_menu_info( + TR::address_details__account_info.into(), + Some(TR::send__send_from.into()), + account_paragraphs, + ); + + // Cancel + let content_cancel = TextScreen::new( + Paragraph::new(&theme::TEXT_REGULAR, TR::send__cancel_sign) + .into_paragraphs() + .with_placement(LinearPlacement::vertical()), + ) + .with_header(Header::new(TR::words__send.into())) + .with_action_bar(ActionBar::new_double( + Button::with_icon(theme::ICON_CHEVRON_LEFT), + Button::with_text(TR::buttons__cancel.into()).styled(theme::button_cancel()), + )) + .map(|msg| match msg { + TextScreenMsg::Confirmed => Some(FlowMsg::Confirmed), + TextScreenMsg::Cancelled => Some(FlowMsg::Cancelled), + _ => None, + }); + + // Cancelled + let content_cancelled = TextScreen::new( + Paragraph::new(&theme::TEXT_REGULAR, TR::send__sign_cancelled) + .into_paragraphs() + .with_placement(LinearPlacement::vertical()), + ) + .with_header(Header::new(TR::words__title_done.into()).with_icon(theme::ICON_DONE, theme::GREY)) + .with_action_bar(ActionBar::new_timeout( + Button::with_text(TR::instructions__continue_in_app.into()), + TIMEOUT_MS, + )) + .map(|_| Some(FlowMsg::Confirmed)); + + let mut res = SwipeFlow::new(&ConfirmSummary::Summary)?; + res.add_page(&ConfirmSummary::Summary, content_summary)? + .add_page(&ConfirmSummary::Menu, content_menu)? + .add_page(&ConfirmSummary::ExtraInfo, content_extra)? + .add_page(&ConfirmSummary::AccountInfo, content_account)? + .add_page(&ConfirmSummary::Cancel, content_cancel)? + .add_page(&ConfirmSummary::Cancelled, content_cancelled)?; + + Ok(res) +} diff --git a/core/embed/rust/src/ui/layout_eckhart/flow/mod.rs b/core/embed/rust/src/ui/layout_eckhart/flow/mod.rs index 5c0785458d..c7310c0fc5 100644 --- a/core/embed/rust/src/ui/layout_eckhart/flow/mod.rs +++ b/core/embed/rust/src/ui/layout_eckhart/flow/mod.rs @@ -1,5 +1,6 @@ pub mod confirm_reset; pub mod confirm_set_new_pin; +pub mod confirm_summary; pub mod continue_recovery_homepage; pub mod get_address; pub mod prompt_backup; @@ -9,6 +10,7 @@ pub mod show_share_words; pub use confirm_reset::new_confirm_reset; pub use confirm_set_new_pin::new_set_new_pin; +pub use confirm_summary::new_confirm_summary; pub use continue_recovery_homepage::new_continue_recovery_homepage; pub use get_address::GetAddress; pub use prompt_backup::PromptBackup; diff --git a/core/embed/rust/src/ui/layout_eckhart/ui_firmware.rs b/core/embed/rust/src/ui/layout_eckhart/ui_firmware.rs index 3483ea2c18..cf21b6b37a 100644 --- a/core/embed/rust/src/ui/layout_eckhart/ui_firmware.rs +++ b/core/embed/rust/src/ui/layout_eckhart/ui_firmware.rs @@ -1,4 +1,5 @@ use core::cmp::Ordering; +use heapless::Vec; use crate::{ error::Error, @@ -217,17 +218,52 @@ impl FirmwareUI for UIEckhart { } fn confirm_summary( - _amount: TString<'static>, - _amount_label: TString<'static>, - _fee: TString<'static>, - _fee_label: TString<'static>, - _title: Option>, - _account_items: Option, - _extra_items: Option, - _extra_title: Option>, - _verb_cancel: Option>, + amount: TString<'static>, + amount_label: TString<'static>, + fee: TString<'static>, + fee_label: TString<'static>, + title: Option>, + account_items: Option, + extra_items: Option, + extra_title: Option>, + verb_cancel: Option>, ) -> Result { - Err::, Error>(Error::ValueError(c"not implemented")) + // collect available info + let account_paragraphs = if let Some(items) = account_items { + let mut paragraphs = ParagraphVecShort::new(); + for pair in IterBuf::new().try_iterate(items)? { + let [label, value]: [TString; 2] = util::iter_into_array(pair)?; + unwrap!(paragraphs.push(Paragraph::new(&theme::TEXT_SMALL_LIGHT, label).no_break())); + unwrap!(paragraphs.push(Paragraph::new(&theme::TEXT_MONO_LIGHT, value))); + } + Some(paragraphs) + } else { + None + }; + let extra_paragraphs = if let Some(items) = extra_items { + let mut paragraphs = ParagraphVecShort::new(); + for pair in IterBuf::new().try_iterate(items)? { + let [label, value]: [TString; 2] = util::iter_into_array(pair)?; + unwrap!(paragraphs.push(Paragraph::new(&theme::TEXT_SMALL_LIGHT, label).no_break())); + unwrap!(paragraphs.push(Paragraph::new(&theme::TEXT_MONO_LIGHT, value))); + } + Some(paragraphs) + } else { + None + }; + + let flow = flow::new_confirm_summary( + title.unwrap_or(TString::empty()), + amount, + amount_label, + fee, + fee_label, + account_paragraphs, + extra_title, + extra_paragraphs, + verb_cancel, + )?; + Ok(flow) } fn confirm_properties( diff --git a/core/src/trezor/ui/layouts/eckhart/__init__.py b/core/src/trezor/ui/layouts/eckhart/__init__.py index c53b094f36..f52fd883c1 100644 --- a/core/src/trezor/ui/layouts/eckhart/__init__.py +++ b/core/src/trezor/ui/layouts/eckhart/__init__.py @@ -677,14 +677,14 @@ def confirm_total( br_name: str = "confirm_total", br_code: ButtonRequestType = ButtonRequestType.SignTx, ) -> Awaitable[None]: - title = title or TR.words__title_summary # def_arg + title = title or TR.words__send # def_arg total_label = total_label or TR.send__total_amount # def_arg fee_label = fee_label or TR.send__incl_transaction_fee # def_arg fee_items = [] account_items = [] if source_account: - account_items.append((TR.confirm_total__sending_from_account, source_account)) + account_items.append((TR.words__account, source_account)) if source_account_path: account_items.append((TR.address_details__derivation_path, source_account_path)) if fee_rate_amount: @@ -718,7 +718,7 @@ def _confirm_summary( br_name: str = "confirm_total", br_code: ButtonRequestType = ButtonRequestType.SignTx, ) -> Awaitable[None]: - title = title or TR.words__title_summary # def_arg + title = title or TR.words__send # def_arg return raise_if_not_confirmed( trezorui_api.confirm_summary(