diff --git a/core/embed/rust/librust_qstr.h b/core/embed/rust/librust_qstr.h index 4aa99ccd3f..2b803aca3e 100644 --- a/core/embed/rust/librust_qstr.h +++ b/core/embed/rust/librust_qstr.h @@ -648,6 +648,8 @@ static void _librust_qstrs(void) { MP_QSTR_send__maximum_fee; MP_QSTR_send__receiving_to_multisig; MP_QSTR_send__send_from; + MP_QSTR_send__send_in_the_app; + MP_QSTR_send__sign_cancelled; MP_QSTR_send__sign_transaction; MP_QSTR_send__title_confirm_sending; MP_QSTR_send__title_joint_transaction; @@ -818,6 +820,7 @@ static void _librust_qstrs(void) { MP_QSTR_words__receive; MP_QSTR_words__recipient; MP_QSTR_words__recovery_share; + MP_QSTR_words__send; MP_QSTR_words__settings; MP_QSTR_words__sign; MP_QSTR_words__signer; @@ -833,6 +836,7 @@ static void _librust_qstrs(void) { MP_QSTR_words__title_threshold; MP_QSTR_words__try_again; MP_QSTR_words__unknown; + MP_QSTR_words__wallet; MP_QSTR_words__warning; MP_QSTR_words__writable; MP_QSTR_words__yes; diff --git a/core/embed/rust/src/translations/generated/translated_string.rs b/core/embed/rust/src/translations/generated/translated_string.rs index 1ce1235b0e..6578807e3c 100644 --- a/core/embed/rust/src/translations/generated/translated_string.rs +++ b/core/embed/rust/src/translations/generated/translated_string.rs @@ -1264,7 +1264,7 @@ pub enum TranslatedString { pin__cancel_description = 870, // "Continue without PIN" pin__cancel_info = 871, // "Without a PIN, anyone can access this device." pin__cancel_setup = 872, // "Cancel PIN setup" - send__cancel_sign = 873, // "Cancel sign" + send__cancel_sign = 873, // {"Bolt": "Cancel sign", "Caesar": "Cancel sign", "Delizia": "Cancel sign", "Eckhart": "Cancel sign?"} send__send_from = 874, // "Send from" instructions__hold_to_sign = 875, // "Hold to sign" confirm_total__fee_rate = 876, // "Fee rate" @@ -1415,6 +1415,10 @@ pub enum TranslatedString { reset__recovery_share_description = 1001, // "A recovery share is a list of words you wrote down when setting up your Trezor." reset__recovery_share_number = 1002, // "Your wallet backup consists of 1 to 16 shares." words__recovery_share = 1003, // "Recovery share" + send__send_in_the_app = 1004, // "After signing, send the transaction in the app." + send__sign_cancelled = 1005, // "Sign cancelled." + words__send = 1006, // "Send" + words__wallet = 1007, // "Wallet" } impl TranslatedString { @@ -2738,7 +2742,14 @@ impl TranslatedString { Self::pin__cancel_description => "Continue without PIN", Self::pin__cancel_info => "Without a PIN, anyone can access this device.", Self::pin__cancel_setup => "Cancel PIN setup", + #[cfg(feature = "layout_bolt")] Self::send__cancel_sign => "Cancel sign", + #[cfg(feature = "layout_caesar")] + Self::send__cancel_sign => "Cancel sign", + #[cfg(feature = "layout_delizia")] + Self::send__cancel_sign => "Cancel sign", + #[cfg(feature = "layout_eckhart")] + Self::send__cancel_sign => "Cancel sign?", Self::send__send_from => "Send from", Self::instructions__hold_to_sign => "Hold to sign", Self::confirm_total__fee_rate => "Fee rate", @@ -2924,6 +2935,10 @@ impl TranslatedString { Self::reset__recovery_share_description => "A recovery share is a list of words you wrote down when setting up your Trezor.", Self::reset__recovery_share_number => "Your wallet backup consists of 1 to 16 shares.", Self::words__recovery_share => "Recovery share", + Self::send__send_in_the_app => "After signing, send the transaction in the app.", + Self::send__sign_cancelled => "Sign cancelled.", + Self::words__send => "Send", + Self::words__wallet => "Wallet", } } @@ -4334,6 +4349,10 @@ impl TranslatedString { Qstr::MP_QSTR_reset__recovery_share_description => Some(Self::reset__recovery_share_description), Qstr::MP_QSTR_reset__recovery_share_number => Some(Self::reset__recovery_share_number), Qstr::MP_QSTR_words__recovery_share => Some(Self::words__recovery_share), + Qstr::MP_QSTR_send__send_in_the_app => Some(Self::send__send_in_the_app), + Qstr::MP_QSTR_send__sign_cancelled => Some(Self::send__sign_cancelled), + Qstr::MP_QSTR_words__send => Some(Self::words__send), + Qstr::MP_QSTR_words__wallet => Some(Self::words__wallet), _ => None, } } diff --git a/core/embed/rust/src/ui/layout_eckhart/flow/confirm_output.rs b/core/embed/rust/src/ui/layout_eckhart/flow/confirm_output.rs new file mode 100644 index 0000000000..f88765c54a --- /dev/null +++ b/core/embed/rust/src/ui/layout_eckhart/flow/confirm_output.rs @@ -0,0 +1,520 @@ +use heapless::Vec; + +use crate::{ + error, + micropython::obj::Obj, + strutil::TString, + translations::TR, + ui::{ + button_request::ButtonRequest, + component::{ + text::paragraphs::{Paragraph, ParagraphSource, ParagraphVecShort, Paragraphs}, + ButtonRequestExt, ComponentExt, MsgMap, + }, + flow::{ + base::{Decision, DecisionBuilder as _}, + FlowController, FlowMsg, SwipeFlow, + }, + geometry::{Alignment, Direction, LinearPlacement, Offset}, + }, +}; + +use super::super::{ + component::Button, + firmware::{ + ActionBar, Header, TextScreen, TextScreenMsg, VerticalMenu, VerticalMenuScreen, + VerticalMenuScreenMsg, + }, + theme, +}; + +const MENU_ITEM_CANCEL: usize = 0; +const MENU_ITEM_FEE_INFO: usize = 1; +const MENU_ITEM_ADDRESS_INFO: usize = 2; +const MENU_ITEM_ACCOUNT_INFO: usize = 3; +const MENU_ITEM_EXTRA_INFO: usize = 4; + +const TIMEOUT_MS: u32 = 2000; + +#[derive(Copy, Clone, PartialEq, Eq)] +pub enum ConfirmOutput { + Address, + Menu, + AccountInfo, + Cancel, + Cancelled, +} + +impl FlowController for ConfirmOutput { + #[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::Address, FlowMsg::Confirmed) => self.return_msg(FlowMsg::Confirmed), + (Self::Address, FlowMsg::Info) => Self::Menu.goto(), + (Self::Menu, FlowMsg::Choice(MENU_ITEM_CANCEL)) => Self::Cancel.goto(), + (Self::Menu, FlowMsg::Choice(MENU_ITEM_ACCOUNT_INFO)) => Self::AccountInfo.goto(), + (Self::Menu, FlowMsg::Cancelled) => Self::Address.goto(), + (Self::AccountInfo, FlowMsg::Cancelled) => Self::Menu.goto(), + (Self::Cancel, FlowMsg::Confirmed) => Self::Cancelled.goto(), + (Self::Cancel, FlowMsg::Cancelled) => Self::Menu.goto(), + (Self::Cancelled, _) => self.return_msg(FlowMsg::Cancelled), + _ => self.do_nothing(), + } + } +} + +#[derive(Copy, Clone, PartialEq, Eq)] +pub enum ConfirmOutputWithAmount { + Address, + AddressMenu, + AddressAccountInfo, + AddressCancel, + Amount, + AmountMenu, + AmountAccountInfo, + AmountCancel, + Cancelled, +} + +impl FlowController for ConfirmOutputWithAmount { + #[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::Address, FlowMsg::Confirmed) => Self::Amount.goto(), + (Self::Address, FlowMsg::Info) => Self::AddressMenu.goto(), + (Self::AddressMenu, FlowMsg::Choice(MENU_ITEM_CANCEL)) => Self::AddressCancel.goto(), + (Self::AddressMenu, FlowMsg::Choice(MENU_ITEM_ACCOUNT_INFO)) => { + Self::AddressAccountInfo.goto() + } + (Self::AddressAccountInfo, FlowMsg::Cancelled) => Self::AddressMenu.goto(), + (Self::AddressMenu, FlowMsg::Cancelled) => Self::Address.goto(), + (Self::AddressCancel, FlowMsg::Confirmed) => Self::Cancelled.goto(), + (Self::AddressCancel, FlowMsg::Cancelled) => Self::AddressMenu.goto(), + (Self::Amount, FlowMsg::Confirmed) => self.return_msg(FlowMsg::Confirmed), + (Self::Amount, FlowMsg::Cancelled) => Self::Address.goto(), + (Self::Amount, FlowMsg::Info) => Self::AmountMenu.goto(), + (Self::AmountMenu, FlowMsg::Choice(MENU_ITEM_CANCEL)) => Self::AmountCancel.goto(), + (Self::AmountMenu, FlowMsg::Choice(MENU_ITEM_ACCOUNT_INFO)) => { + Self::AmountAccountInfo.goto() + } + (Self::AmountAccountInfo, FlowMsg::Cancelled) => Self::AmountMenu.goto(), + (Self::AmountMenu, FlowMsg::Cancelled) => Self::Amount.goto(), + (Self::AmountCancel, FlowMsg::Confirmed) => Self::Cancelled.goto(), + (Self::AmountCancel, FlowMsg::Cancelled) => Self::AmountMenu.goto(), + (Self::Cancelled, _) => self.return_msg(FlowMsg::Cancelled), + _ => self.do_nothing(), + } + } +} + +#[derive(Copy, Clone, PartialEq, Eq)] +pub enum ConfirmOutputWithSummary { + Main, + MainMenu, + MainMenuCancel, + MainMenuAddresInfo, + MainMenuAccountInfo, + Summary, + SummaryMenu, + SummaryMenuCancel, + SummaryMenuFeeInfo, + SummaryMenuAccountInfo, + Cancelled, +} + +impl FlowController for ConfirmOutputWithSummary { + #[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::Main, FlowMsg::Confirmed) => Self::Summary.goto(), + (Self::Main, FlowMsg::Info) => Self::MainMenu.goto(), + (Self::MainMenu, FlowMsg::Choice(MENU_ITEM_CANCEL)) => Self::MainMenuCancel.goto(), + (Self::MainMenu, FlowMsg::Choice(MENU_ITEM_ADDRESS_INFO)) => { + Self::MainMenuAddresInfo.goto() + } + (Self::MainMenu, FlowMsg::Choice(MENU_ITEM_ACCOUNT_INFO)) => { + Self::MainMenuAccountInfo.goto() + } + (Self::MainMenu, FlowMsg::Cancelled) => Self::Main.goto(), + (Self::MainMenuAccountInfo | Self::MainMenuAddresInfo, FlowMsg::Cancelled) => { + Self::MainMenu.goto() + } + (Self::MainMenuCancel, FlowMsg::Cancelled) => Self::MainMenu.goto(), + (Self::MainMenuCancel, FlowMsg::Confirmed) => Self::Cancelled.goto(), + (Self::Summary, FlowMsg::Info) => Self::SummaryMenu.goto(), + (Self::Summary, FlowMsg::Cancelled) => Self::Main.goto(), + (Self::Summary, FlowMsg::Confirmed) => self.return_msg(FlowMsg::Confirmed), + (Self::SummaryMenu, FlowMsg::Choice(MENU_ITEM_CANCEL)) => { + Self::SummaryMenuCancel.goto() + } + (Self::SummaryMenu, FlowMsg::Choice(MENU_ITEM_FEE_INFO)) => { + Self::SummaryMenuFeeInfo.goto() + } + (Self::SummaryMenu, FlowMsg::Choice(MENU_ITEM_EXTRA_INFO)) => { + Self::SummaryMenuAccountInfo.goto() + } + (Self::SummaryMenu, FlowMsg::Cancelled) => Self::Summary.goto(), + (Self::SummaryMenuCancel, FlowMsg::Cancelled) => Self::SummaryMenu.goto(), + (Self::SummaryMenuCancel, FlowMsg::Confirmed) => Self::Cancelled.goto(), + (Self::SummaryMenuAccountInfo | Self::SummaryMenuFeeInfo, FlowMsg::Cancelled) => { + Self::SummaryMenu.goto() + } + (Self::Cancelled, _) => self.return_msg(FlowMsg::Cancelled), + _ => self.do_nothing(), + } + } +} + +fn content_cancel( +) -> MsgMap>>, impl Fn(TextScreenMsg) -> Option> { + 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, + }) +} + +fn content_main_menu( + address_title: TString<'static>, + address_params: bool, + account_params: bool, + cancel_menu_label: TString<'static>, +) -> MsgMap Option> { + let mut main_menu = VerticalMenu::empty(); + let mut main_menu_items = Vec::::new(); + if address_params { + main_menu = main_menu.item( + Button::with_text(address_title) + .styled(theme::menu_item_title()) + .with_text_align(Alignment::Start), + ); + unwrap!(main_menu_items.push(MENU_ITEM_ADDRESS_INFO)); + } + if account_params { + main_menu = main_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!(main_menu_items.push(MENU_ITEM_ACCOUNT_INFO)); + } + main_menu = main_menu.item( + Button::with_text(cancel_menu_label) + .styled(theme::menu_item_title_orange()) + .with_text_align(Alignment::Start) + .with_content_offset(Offset::x(12)), + ); + unwrap!(main_menu_items.push(MENU_ITEM_CANCEL)); + + VerticalMenuScreen::new(main_menu) + .with_header(Header::new(TString::empty()).with_close_button()) + .map(move |msg| match msg { + VerticalMenuScreenMsg::Selected(i) => { + let selected_item = main_menu_items[i]; + Some(FlowMsg::Choice(selected_item)) + } + VerticalMenuScreenMsg::Close => Some(FlowMsg::Cancelled), + _ => None, + }) +} + +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_output( + title: Option>, + subtitle: Option>, + chunkify: bool, + message: Obj, + amount: Option, + br_name: TString<'static>, + br_code: u16, + account_title: TString<'static>, + account_paragraphs: Option>, + address_title: Option>, + address_paragraphs: Option>, + summary_title: Option>, + summary_paragraphs: Option>, + summary_br_code: Option, + summary_br_name: Option>, + fee_params: Option>, + cancel_menu_label: Option>, +) -> Result { + let cancel_menu_label = cancel_menu_label.unwrap_or(TR::buttons__cancel.into()); + let address_menu_item = address_paragraphs.is_some(); + let account_menu_item = account_paragraphs.is_some(); + let fee_menu_item = fee_params.is_some(); + let address_title = address_title.unwrap_or(TR::words__address.into()); + let account_subtitle = Some(TR::send__send_from.into()); + + // Main + let main_paragraphs = Paragraph::new( + if chunkify { + &theme::TEXT_MONO_ADDRESS_CHUNKS + } else { + &theme::TEXT_MONO_LIGHT + }, + message.try_into().unwrap_or(TString::empty()), + ); + let content_main = TextScreen::new( + main_paragraphs + .into_paragraphs() + .with_placement(LinearPlacement::vertical()), + ) + .with_header(Header::new(title.unwrap_or(TString::empty())).with_menu_button()) + .with_action_bar(ActionBar::new_single(Button::with_text( + TR::buttons__continue.into(), + ))) + .with_subtitle(subtitle.unwrap_or(TString::empty())) + .map(|msg| match msg { + TextScreenMsg::Confirmed => Some(FlowMsg::Confirmed), + TextScreenMsg::Cancelled => Some(FlowMsg::Cancelled), + TextScreenMsg::Menu => Some(FlowMsg::Info), + }) + .one_button_request(ButtonRequest::from_num(br_code, br_name)) + .with_pages(|_| 1); + + // 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 res = if let Some(amount) = amount { + let amount_paragraphs = ParagraphVecShort::from_iter([ + Paragraph::new(&theme::TEXT_SMALL_LIGHT, TR::words__amount).no_break(), + Paragraph::new( + &theme::TEXT_MONO_MEDIUM_LIGHT, + amount.try_into().unwrap_or(TString::empty()), + ), + ]); + + let content_amount = TextScreen::new( + amount_paragraphs + .into_paragraphs() + .with_placement(LinearPlacement::vertical()), + ) + .with_header(Header::new(TR::words__amount.into()).with_menu_button()) + .with_action_bar(ActionBar::new_double( + Button::with_icon(theme::ICON_CHEVRON_UP), + Button::with_text(TR::buttons__confirm.into()).styled(theme::button_confirm()), + )) + .with_subtitle(subtitle.unwrap_or(TString::empty())) + .map(|msg| match msg { + TextScreenMsg::Confirmed => Some(FlowMsg::Confirmed), + TextScreenMsg::Cancelled => Some(FlowMsg::Cancelled), + TextScreenMsg::Menu => Some(FlowMsg::Info), + }) + .one_button_request(ButtonRequest::from_num(br_code, br_name)) + .with_pages(|_| 1); + + let mut flow = SwipeFlow::new(&ConfirmOutputWithAmount::Address)?; + flow.add_page(&ConfirmOutputWithAmount::Address, content_main)? + .add_page( + &ConfirmOutputWithAmount::AddressMenu, + content_main_menu( + address_title, + address_menu_item, + account_menu_item, + cancel_menu_label, + ), + )? + .add_page( + &ConfirmOutputWithAmount::AddressAccountInfo, + content_menu_info( + TR::address_details__account_info.into(), + account_subtitle, + account_paragraphs.clone(), + ), + )? + .add_page(&ConfirmOutputWithAmount::AddressCancel, content_cancel())? + .add_page(&ConfirmOutputWithAmount::Amount, content_amount)? + .add_page( + &ConfirmOutputWithAmount::AmountMenu, + content_main_menu( + address_title, + address_menu_item, + account_menu_item, + cancel_menu_label, + ), + )? + .add_page( + &ConfirmOutputWithAmount::AmountAccountInfo, + content_menu_info(account_title, account_subtitle, account_paragraphs.clone()), + )? + .add_page(&ConfirmOutputWithAmount::AmountCancel, content_cancel())? + .add_page(&ConfirmOutputWithAmount::Cancelled, content_cancelled)?; + flow + } else if let Some(summary_paragraphs) = summary_paragraphs { + // Summary + let content_summary = TextScreen::new( + summary_paragraphs + .into_paragraphs() + .with_placement(LinearPlacement::vertical()) + .with_spacing(12), + ) + .with_header( + Header::new(summary_title.unwrap_or(TR::words__title_summary.into())) + .with_menu_button(), + ) + .with_action_bar(ActionBar::new_double( + Button::with_icon(theme::ICON_CHEVRON_UP), + Button::with_text(TR::instructions__hold_to_sign.into()) + .styled(theme::button_confirm()), + )) + .map(|msg| match msg { + TextScreenMsg::Confirmed => Some(FlowMsg::Confirmed), + TextScreenMsg::Cancelled => Some(FlowMsg::Cancelled), + TextScreenMsg::Menu => Some(FlowMsg::Info), + }) + .one_button_request(ButtonRequest::from_num( + summary_br_code.unwrap(), + summary_br_name.unwrap(), + )) + .with_pages(|_| 1); + + // SummaryMenu + let mut summary_menu = VerticalMenu::empty(); + let mut summary_menu_items = Vec::::new(); + if account_menu_item { + summary_menu = summary_menu.item(Button::with_text(account_title)); + unwrap!(summary_menu_items.push(MENU_ITEM_EXTRA_INFO)); + } + if fee_menu_item { + summary_menu = + summary_menu.item(Button::with_text(TR::confirm_total__title_fee.into())); + unwrap!(summary_menu_items.push(MENU_ITEM_FEE_INFO)); + } + summary_menu = summary_menu + .item(Button::with_text(cancel_menu_label).styled(theme::menu_item_title_orange())); + unwrap!(summary_menu_items.push(MENU_ITEM_CANCEL)); + let content_summary_menu = VerticalMenuScreen::new(summary_menu) + .with_header(Header::new(TString::empty()).with_close_button()) + .map(move |msg| match msg { + VerticalMenuScreenMsg::Selected(i) => { + let selected_item = summary_menu_items[i]; + Some(FlowMsg::Choice(selected_item)) + } + VerticalMenuScreenMsg::Close => Some(FlowMsg::Cancelled), + _ => None, + }); + + let mut flow = SwipeFlow::new(&ConfirmOutputWithSummary::Main)?; + flow.add_page(&ConfirmOutputWithSummary::Main, content_main)? + .add_page( + &ConfirmOutputWithSummary::MainMenu, + content_main_menu( + address_title, + address_menu_item, + account_menu_item, + cancel_menu_label, + ), + )? + .add_page(&ConfirmOutputWithSummary::MainMenuCancel, content_cancel())? + .add_page( + &ConfirmOutputWithSummary::MainMenuAddresInfo, + content_menu_info(address_title, None, address_paragraphs), + )? + .add_page( + &ConfirmOutputWithSummary::MainMenuAccountInfo, + content_menu_info(account_title, account_subtitle, account_paragraphs.clone()), + )? + .add_page(&ConfirmOutputWithSummary::Summary, content_summary)? + .add_page(&ConfirmOutputWithSummary::SummaryMenu, content_summary_menu)? + .add_page( + &ConfirmOutputWithSummary::SummaryMenuCancel, + content_cancel(), + )? + .add_page( + &ConfirmOutputWithSummary::SummaryMenuFeeInfo, + content_menu_info(TR::confirm_total__title_fee.into(), None, fee_params), + )? + .add_page( + &ConfirmOutputWithSummary::SummaryMenuAccountInfo, + content_menu_info(account_title, account_subtitle, account_paragraphs), + )? + .add_page(&ConfirmOutputWithSummary::Cancelled, content_cancelled)?; + flow + } else { + let mut flow = SwipeFlow::new(&ConfirmOutput::Address)?; + flow.add_page(&ConfirmOutput::Address, content_main)? + .add_page( + &ConfirmOutput::Menu, + content_main_menu( + address_title, + address_menu_item, + account_menu_item, + cancel_menu_label, + ), + )? + .add_page( + &ConfirmOutput::AccountInfo, + content_menu_info(account_title, account_subtitle, account_paragraphs), + )? + .add_page(&ConfirmOutput::Cancel, content_cancel())? + .add_page(&ConfirmOutput::Cancelled, content_cancelled)?; + flow + }; + + 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 c7310c0fc5..aea7c2b349 100644 --- a/core/embed/rust/src/ui/layout_eckhart/flow/mod.rs +++ b/core/embed/rust/src/ui/layout_eckhart/flow/mod.rs @@ -1,3 +1,4 @@ +pub mod confirm_output; pub mod confirm_reset; pub mod confirm_set_new_pin; pub mod confirm_summary; @@ -8,6 +9,7 @@ pub mod request_passphrase; pub mod show_danger; pub mod show_share_words; +pub use confirm_output::new_confirm_output; pub use confirm_reset::new_confirm_reset; pub use confirm_set_new_pin::new_set_new_pin; pub use confirm_summary::new_confirm_summary; 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 cf21b6b37a..228d7eca84 100644 --- a/core/embed/rust/src/ui/layout_eckhart/ui_firmware.rs +++ b/core/embed/rust/src/ui/layout_eckhart/ui_firmware.rs @@ -1,5 +1,4 @@ use core::cmp::Ordering; -use heapless::Vec; use crate::{ error::Error, @@ -393,29 +392,114 @@ impl FirmwareUI for UIEckhart { } fn flow_confirm_output( - _title: Option>, - _subtitle: Option>, + title: Option>, + subtitle: Option>, _description: Option>, _extra: Option>, - _message: Obj, - _amount: Option, - _chunkify: bool, + message: Obj, + amount: Option, + chunkify: bool, _text_mono: bool, - _account_title: TString<'static>, - _account: Option>, - _account_path: Option>, - _br_code: u16, - _br_name: TString<'static>, - _address_item: Option<(TString<'static>, Obj)>, + account_title: TString<'static>, + account: Option>, + account_path: Option>, + br_code: u16, + br_name: TString<'static>, + address_item: Option<(TString<'static>, Obj)>, _extra_item: Option<(TString<'static>, Obj)>, - _summary_items: Option, - _fee_items: Option, - _summary_title: Option>, - _summary_br_code: Option, - _summary_br_name: Option>, - _cancel_text: Option>, + summary_items: Option, + fee_items: Option, + summary_title: Option>, + summary_br_code: Option, + summary_br_name: Option>, + cancel_text: Option>, ) -> Result { - Err::, Error>(Error::ValueError(c"not implemented")) + let (address_title, address_paragraphs) = if let Some(address_item) = address_item { + let mut paragraphs = ParagraphVecShort::new(); + for pair in IterBuf::new().try_iterate(address_item.1)? { + 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_MEDIUM_LIGHT, value))); + } + (Some(address_item.0), Some(paragraphs)) + } else { + (None, None) + }; + + // collect available info + let account_paragraphs = { + let mut paragraphs = ParagraphVecShort::new(); + if let Some(account) = account { + unwrap!(paragraphs.push( + Paragraph::new( + &theme::TEXT_SMALL_LIGHT, + TString::from_translation(TR::words__wallet) + ) + .no_break() + )); + unwrap!(paragraphs.push(Paragraph::new(&theme::TEXT_MONO_LIGHT, account))); + } + if let Some(path) = account_path { + unwrap!(paragraphs.push( + Paragraph::new( + &theme::TEXT_SMALL_LIGHT, + TString::from_translation(TR::address_details__derivation_path) + ) + .no_break() + )); + unwrap!(paragraphs.push(Paragraph::new(&theme::TEXT_MONO_LIGHT, path))); + } + if paragraphs.is_empty() { + None + } else { + Some(paragraphs) + } + }; + + let summary_paragraphs = if let Some(items) = summary_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_MEDIUM_LIGHT, value))); + } + Some(paragraphs) + } else { + None + }; + + let fee_paragraphs = if let Some(items) = fee_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::confirm_output::new_confirm_output( + title, + subtitle, + chunkify, + message, + amount, + br_name, + br_code, + account_title, + account_paragraphs, + address_title, + address_paragraphs, + summary_title, + summary_paragraphs, + summary_br_code, + summary_br_name, + fee_paragraphs, + cancel_text, + )?; + Ok(flow) } fn flow_confirm_set_new_pin( diff --git a/core/mocks/trezortranslate_keys.pyi b/core/mocks/trezortranslate_keys.pyi index 320a35243c..c22bfad06e 100644 --- a/core/mocks/trezortranslate_keys.pyi +++ b/core/mocks/trezortranslate_keys.pyi @@ -756,6 +756,8 @@ class TR: send__maximum_fee: str = "Maximum fee" send__receiving_to_multisig: str = "Receiving to a multisig address." send__send_from: str = "Send from" + send__send_in_the_app: str = "After signing, send the transaction in the app." + send__sign_cancelled: str = "Sign cancelled." send__sign_transaction: str = "Sign transaction" send__title_confirm_sending: str = "Confirm sending" send__title_joint_transaction: str = "Joint transaction" @@ -977,6 +979,7 @@ class TR: words__receive: str = "Receive" words__recipient: str = "Recipient" words__recovery_share: str = "Recovery share" + words__send: str = "Send" words__settings: str = "Settings" words__sign: str = "Sign" words__signer: str = "Signer" @@ -992,6 +995,7 @@ class TR: words__title_threshold: str = "Threshold" words__try_again: str = "Try again." words__unknown: str = "Unknown" + words__wallet: str = "Wallet" words__warning: str = "Warning" words__writable: str = "Writable" words__yes: str = "Yes" diff --git a/core/src/trezor/ui/layouts/eckhart/__init__.py b/core/src/trezor/ui/layouts/eckhart/__init__.py index f52fd883c1..7be630835d 100644 --- a/core/src/trezor/ui/layouts/eckhart/__init__.py +++ b/core/src/trezor/ui/layouts/eckhart/__init__.py @@ -392,6 +392,7 @@ async def confirm_output( source_account: str | None = None, source_account_path: str | None = None, cancel_text: str | None = None, + description: str | None = None, ) -> None: if address_label is not None: title = address_label @@ -402,9 +403,32 @@ async def confirm_output( else: title = TR.send__title_sending_to - # FIXME: not implemented - # use `flow_confirm_output` or `TT/TR` style? - raise NotImplementedError + await raise_if_not_confirmed( + trezorui_api.flow_confirm_output( + title=TR.words__address, + subtitle=title, + message=address, + extra=None, + amount=amount, + chunkify=chunkify, + text_mono=True, + account_title=TR.send__send_from, + account=source_account, + account_path=source_account_path, + address_item=None, + extra_item=None, + br_code=br_code, + br_name="confirm_output", + summary_items=None, + fee_items=None, + summary_title=None, + summary_br_name=None, + summary_br_code=None, + cancel_text=cancel_text, + description=description, + ), + br_name=None, + ) async def should_show_payment_request_details( diff --git a/core/translations/en.json b/core/translations/en.json index 3d89e4c90c..3ffdb788c8 100644 --- a/core/translations/en.json +++ b/core/translations/en.json @@ -815,7 +815,13 @@ "sd_card__wanna_format": "Do you really want to format the SD card?", "sd_card__wrong_sd_card": "Wrong SD card.", "send__address_path": "address path", - "send__cancel_sign": "Cancel sign", + "send__cancel_sign": { + "Bolt": "Cancel sign", + "Caesar": "Cancel sign", + "Delizia": "Cancel sign", + "Eckhart": "Cancel sign?" + }, + "send__sign_cancelled": "Sign cancelled.", "send__confirm_sending": "Sending amount", "send__from_multiple_accounts": "Sending from multiple accounts.", "send__incl_transaction_fee": "incl. Transaction fee", @@ -824,6 +830,7 @@ "send__receiving_to_multisig": "Receiving to a multisig address.", "send__send_from": "Send from", "send__sign_transaction": "Sign transaction", + "send__send_in_the_app": "After signing, send the transaction in the app.", "send__title_confirm_sending": "Confirm sending", "send__title_joint_transaction": "Joint transaction", "send__title_receiving_to": "Receiving to", @@ -1044,6 +1051,7 @@ "words__receive": "Receive", "words__recipient": "Recipient", "words__recovery_share": "Recovery share", + "words__send": "Send", "words__settings": "Settings", "words__sign": "Sign", "words__signer": "Signer", @@ -1064,6 +1072,7 @@ "Eckhart": "Try again" }, "words__unknown": "Unknown", + "words__wallet": "Wallet", "words__warning": "Warning", "words__writable": "Writable", "words__yes": "Yes" diff --git a/core/translations/order.json b/core/translations/order.json index fa2dac2759..92bb04145b 100644 --- a/core/translations/order.json +++ b/core/translations/order.json @@ -1002,5 +1002,9 @@ "1000": "words__receive", "1001": "reset__recovery_share_description", "1002": "reset__recovery_share_number", - "1003": "words__recovery_share" + "1003": "words__recovery_share", + "1004": "send__send_in_the_app", + "1005": "send__sign_cancelled", + "1006": "words__send", + "1007": "words__wallet" } diff --git a/core/translations/signatures.json b/core/translations/signatures.json index 842f520e11..ccc199ee1a 100644 --- a/core/translations/signatures.json +++ b/core/translations/signatures.json @@ -1,8 +1,8 @@ { "current": { - "merkle_root": "136cfad983788597b6218f51a94b93b14535f20111dc91aa953e6e9df462ab16", - "datetime": "2025-03-27T18:08:46.572012", - "commit": "b340d6c7b21d110b8bb4478c654b293bd3a977f0" + "merkle_root": "32a4bda1a1ee4fdcef587ba5a1ab26ce37902bc1714e87e77cda9c98bb913c76", + "datetime": "2025-04-08T10:31:11.342473", + "commit": "b6db7571e9e73304b7229b03970662c117b3a1df" }, "history": [ {