diff --git a/harbor-ui/assets/icons/cancel.svg b/harbor-ui/assets/icons/cancel.svg new file mode 100644 index 0000000..77843b6 --- /dev/null +++ b/harbor-ui/assets/icons/cancel.svg @@ -0,0 +1,4 @@ + + + + diff --git a/harbor-ui/assets/icons/small_check.svg b/harbor-ui/assets/icons/small_check.svg new file mode 100644 index 0000000..f1f635e --- /dev/null +++ b/harbor-ui/assets/icons/small_check.svg @@ -0,0 +1,3 @@ + + + diff --git a/harbor-ui/src/components/button.rs b/harbor-ui/src/components/button.rs index 6fb135f..d1de274 100644 --- a/harbor-ui/src/components/button.rs +++ b/harbor-ui/src/components/button.rs @@ -60,6 +60,58 @@ pub fn h_button(text_str: &str, icon: SvgIcon, loading: bool) -> Button<'_, Mess .height(Length::Fixed(64.)) } +pub fn h_small_button(text_str: &str, icon: SvgIcon, loading: bool) -> Button<'_, Message, Theme> { + let spinner: Element<'static, Message, Theme> = the_spinner(); + let svg = map_icon(icon, 16., 16.); + let content = if loading { + row![spinner].align_y(iced::Alignment::Center) + } else if text_str.is_empty() { + row![svg].align_y(iced::Alignment::Center) + } else { + row![svg, text(text_str).size(16.)] + .align_y(iced::Alignment::Center) + .spacing(8) + }; + + Button::new(center(content)) + .style(move |theme, status| { + let gray = lighten(theme.palette().background, 0.5); + + let border_color = if loading || matches!(status, Status::Disabled) { + gray + } else { + Color::WHITE + }; + + let border = Border { + color: border_color, + width: 1.5, + radius: (8.).into(), + }; + + let background = if loading { + theme.palette().background + } else { + match status { + Status::Hovered => lighten(theme.palette().background, 0.1), + Status::Pressed => darken(Color::BLACK, 0.1), + _ => theme.palette().background, + } + }; + + let text_color = if loading { gray } else { Color::WHITE }; + + button::Style { + background: Some(background.into()), + text_color, + border, + shadow: Shadow::default(), + } + }) + .width(Length::Fill) + .height(Length::Fixed(40.)) +} + pub fn sidebar_button( text_str: &str, icon: SvgIcon, diff --git a/harbor-ui/src/components/confirm_modal.rs b/harbor-ui/src/components/confirm_modal.rs new file mode 100644 index 0000000..95c2f8e --- /dev/null +++ b/harbor-ui/src/components/confirm_modal.rs @@ -0,0 +1,82 @@ +use iced::widget::{button, center, column, container, row, stack, text}; +use iced::{Color, Element, Length, Shadow, Theme, Vector}; + +use crate::Message; + +use super::{h_small_button, light_container_style, SvgIcon}; + +#[derive(Debug, Clone)] +pub struct ConfirmModalState { + pub title: String, + pub description: String, + pub confirm_action: Box, + pub cancel_action: Box, + pub confirm_button_text: String, +} + +impl Default for ConfirmModalState { + fn default() -> Self { + Self { + title: "Confirm Action".to_string(), + description: "Are you sure you want to proceed?".to_string(), + confirm_action: Box::new(Message::SetConfirmModal(None)), + cancel_action: Box::new(Message::SetConfirmModal(None)), + confirm_button_text: "Confirm".to_string(), + } + } +} + +pub fn confirm_modal<'a>( + content: Element<'a, Message>, + state: &'a ConfirmModalState, +) -> Element<'a, Message> { + let modal_content = container( + column![ + text(&state.title).size(24), + text(&state.description), + row![ + h_small_button("Cancel", SvgIcon::SmallClose, false) + .on_press((*state.cancel_action).clone()), + h_small_button(&state.confirm_button_text, SvgIcon::SmallCheck, false) + .on_press((*state.confirm_action).clone()), + ] + .spacing(10) + ] + .spacing(20), + ) + .width(400) + .padding(24) + .style(|theme: &Theme| container::Style { + background: Some(theme.palette().background.into()), + text_color: Some(theme.palette().text), + border: light_container_style(theme).border, + shadow: Shadow { + color: Color::from_rgba8(0, 0, 0, 0.5), + offset: Vector::new(4.0, 4.0), + blur_radius: 8.0, + }, + }); + + stack![ + content, + // This layer blocks all pointer events from reaching the content below + // TODO: can we do something cleaner? + button(container(text("")).width(Length::Fill).height(Length::Fill)) + .on_press((*state.cancel_action).clone()) + .style(|_theme: &Theme, _state| button::Style::default()), + container(center(modal_content)) + .width(Length::Fill) + .height(Length::Fill) + .style(|_theme: &Theme| container::Style { + background: Some( + Color { + a: 0.8, + ..Color::BLACK + } + .into() + ), + ..container::Style::default() + }) + ] + .into() +} diff --git a/harbor-ui/src/components/federation_item.rs b/harbor-ui/src/components/federation_item.rs index 8a0df45..5f786b1 100644 --- a/harbor-ui/src/components/federation_item.rs +++ b/harbor-ui/src/components/federation_item.rs @@ -1,11 +1,14 @@ use crate::Message; use harbor_client::db_models::FederationItem; use iced::{ - widget::{column, container, row, text}, - Alignment, Element, + widget::{column, container, horizontal_space, row, text}, + Alignment, Element, Length, }; -use super::{h_balance_display, h_button, light_container_style, map_icon, subtitle, SvgIcon}; +use super::{ + h_balance_display, h_button, h_small_button, light_container_style, map_icon, subtitle, + ConfirmModalState, SvgIcon, +}; pub fn h_federation_item(item: &FederationItem, invite_code: Option) -> Element { let FederationItem { @@ -43,9 +46,19 @@ pub fn h_federation_item(item: &FederationItem, invite_code: Option) -> None => { column = column.push(h_balance_display(*balance)); - let remove_button = h_button("Remove Mint", SvgIcon::Trash, false) - .on_press(Message::RemoveFederation(*id)); - column = column.push(remove_button); + let remove_button = h_small_button("", SvgIcon::Trash, false).on_press( + Message::SetConfirmModal(Some(ConfirmModalState { + title: "Are you sure?".to_string(), + description: format!("This will remove {} from your list of mints.", name), + confirm_action: Box::new(Message::RemoveFederation(*id)), + cancel_action: Box::new(Message::SetConfirmModal(None)), + confirm_button_text: "Remove Mint".to_string(), + })), + ); + column = column.push(row![ + horizontal_space().width(Length::Fill), + remove_button.width(48) + ]); } } diff --git a/harbor-ui/src/components/icon.rs b/harbor-ui/src/components/icon.rs index 12454f3..42db983 100644 --- a/harbor-ui/src/components/icon.rs +++ b/harbor-ui/src/components/icon.rs @@ -20,6 +20,7 @@ pub enum SvgIcon { Qr, Restart, SmallClose, + SmallCheck, Bolt, Chain, Eye, @@ -56,6 +57,7 @@ pub fn map_icon<'a>(icon: SvgIcon, width: f32, height: f32) -> Svg<'a, Theme> { SvgIcon::Qr => icon_handle!("qr.svg"), SvgIcon::Restart => icon_handle!("restart.svg"), SvgIcon::SmallClose => icon_handle!("small_close.svg"), + SvgIcon::SmallCheck => icon_handle!("small_check.svg"), SvgIcon::Bolt => icon_handle!("bolt.svg"), SvgIcon::Chain => icon_handle!("chain.svg"), SvgIcon::Eye => icon_handle!("eye.svg"), diff --git a/harbor-ui/src/components/mod.rs b/harbor-ui/src/components/mod.rs index ef8db62..82e4f2b 100644 --- a/harbor-ui/src/components/mod.rs +++ b/harbor-ui/src/components/mod.rs @@ -62,3 +62,6 @@ pub use balance_display::*; pub mod absolute_overlay; pub use absolute_overlay::*; + +mod confirm_modal; +pub use confirm_modal::*; diff --git a/harbor-ui/src/main.rs b/harbor-ui/src/main.rs index 3320b0d..8971fd4 100644 --- a/harbor-ui/src/main.rs +++ b/harbor-ui/src/main.rs @@ -120,6 +120,7 @@ pub enum Message { UIHandlerLoaded(Arc), // Local state changes Navigate(Route), + SetConfirmModal(Option), ReceiveAmountChanged(String), ReceiveStateReset, SendDestInputChanged(String), @@ -141,6 +142,8 @@ pub enum Message { TransferAmountInputChanged(String), UrlClicked(String), SelectTransaction(Option), + // Batch multiple messages together + Batch(Vec), // Async commands we fire from the UI to core Noop, Send(String), @@ -177,6 +180,8 @@ pub struct HarborWallet { selected_transaction: Option, federation_list: Vec, active_federation_id: Option, + // Modal + confirm_modal: Option, // Welcome screen init_status: WelcomeStatus, seed_input_str: String, @@ -317,6 +322,9 @@ impl HarborWallet { println!("Core loaded"); Task::none() } + Message::Batch(messages) => { + Task::batch(messages.into_iter().map(|msg| self.update(msg))) + } // Internal app state stuff like navigation and text inputs Message::Navigate(route) => { match self.active_route { @@ -729,6 +737,10 @@ impl HarborWallet { self.selected_transaction = transaction; Task::none() } + Message::SetConfirmModal(modal_state) => { + self.confirm_modal = modal_state; + Task::none() + } // Handle any messages we get from core Message::CoreMessage(msg) => match msg.msg { CoreUIMsg::Sending => { @@ -936,6 +948,8 @@ impl HarborWallet { self.clear_add_federation_state(); // Route to the mints list self.active_route = Route::Mints(routes::MintSubroute::List); + // We probably got here because of a modal so we should close the modal + self.confirm_modal = None; Task::perform(async {}, |_| { Message::AddToast(Toast { title: "Mint removed".to_string(), @@ -1039,7 +1053,13 @@ impl HarborWallet { Route::Welcome => crate::routes::welcome(self), }; - ToastManager::new(active_route, &self.toasts, Message::CloseToast).into() + let content = if let Some(modal_state) = &self.confirm_modal { + crate::components::confirm_modal(active_route, modal_state) + } else { + active_route + }; + + ToastManager::new(content, &self.toasts, Message::CloseToast).into() } fn theme(&self) -> iced::Theme { diff --git a/harbor-ui/src/routes/settings.rs b/harbor-ui/src/routes/settings.rs index dea9389..1078123 100644 --- a/harbor-ui/src/routes/settings.rs +++ b/harbor-ui/src/routes/settings.rs @@ -26,6 +26,26 @@ pub fn settings(harbor: &HarborWallet) -> Element { status: ToastStatus::Bad, })); + let test_confirm_modal_button = h_button("Test Confirm Modal", SvgIcon::Shield, false) + .on_press(Message::SetConfirmModal(Some( + crate::components::ConfirmModalState { + title: "Test Modal".to_string(), + description: + "This is a test of the confirm modal. Are you sure you want to proceed?" + .to_string(), + confirm_action: Box::new(Message::Batch(vec![ + Message::AddToast(Toast { + title: "You confirmed!".to_string(), + body: None, + status: ToastStatus::Good, + }), + Message::SetConfirmModal(None), + ])), + cancel_action: Box::new(Message::SetConfirmModal(None)), + confirm_button_text: "Confirm".to_string(), + }, + ))); + let column = match (harbor.settings_show_seed_words, &harbor.seed_words) { (true, Some(s)) => { let button = h_button("Hide Seed Words", SvgIcon::EyeClosed, false) @@ -47,7 +67,8 @@ pub fn settings(harbor: &HarborWallet) -> Element { button, onchain_receive_checkbox, add_good_toast_button, - add_error_toast_button + add_error_toast_button, + test_confirm_modal_button ] } };