diff --git a/packages/oneclient_app/src/components/checkbox.rs b/packages/oneclient_app/src/components/checkbox.rs new file mode 100644 index 00000000..d2216074 --- /dev/null +++ b/packages/oneclient_app/src/components/checkbox.rs @@ -0,0 +1,133 @@ +use freya::{ + animation::{AnimColor, AnimNum, AnimatedValue, Ease, Function, use_animation_transition}, + prelude::*, +}; + +use crate::{ + components::{Icon, IconType}, + theme::colors, + ui, +}; + +const BOX_SIZE: f32 = 18.; +const MARK_SIZE: f32 = 12.; +const TIME: u64 = 180; + +pub fn checkbox_labeled(value: State, text: impl Into>) -> impl IntoElement { + let mut v = value; + Check { + value, + label: Some(text.into()), + on_press: (move |()| v.toggle()).into(), + disabled: false, + } +} + +#[derive(PartialEq)] +struct Check { + value: State, + label: Option>, + on_press: EventHandler<()>, + disabled: bool, +} + +impl Component for Check { + fn render(&self) -> impl IntoElement { + let value = self.value; + let on_press = self.on_press.clone(); + let checked = *value.read(); + let disabled = self.disabled; + + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + + let box_border = if focus().is_focused() && !disabled { + colors::fg_primary() + } else if checked && !disabled { + colors::brand() + } else { + colors::component_border() + }; + + let background = use_animation_transition(value, |_, checked| { + if checked { + AnimColor::new(colors::component_bg(), colors::brand()) + } else { + AnimColor::new(colors::brand(), colors::component_bg()) + } + .time(TIME) + .function(Function::Expo) + .ease(Ease::Out) + }); + + let mark = use_animation_transition(value, |_, checked| { + let scale = AnimNum::new(0.5, 1.) + .time(TIME) + .function(Function::Expo) + .ease(Ease::Out); + let opacity = AnimNum::new(0., 1.) + .time(TIME) + .function(Function::Expo) + .ease(Ease::Out); + + if checked { + (scale, opacity) + } else { + (scale.into_reversed(), opacity.into_reversed()) + } + }); + + let (mark_scale, mark_opacity) = { + let mark = mark.read(); + (mark.0.value(), mark.1.value()) + }; + + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(8.) + .a11y_id(a11y_id) + .a11y_focusable(!disabled) + .a11y_role(AccessibilityRole::CheckBox) + .maybe(!disabled, |el| { + el.cursor(CursorIcon::Pointer).on_press(move |_| { + on_press.call(()); + }) + }) + .child( + rect() + .width(Size::px(BOX_SIZE)) + .height(Size::px(BOX_SIZE)) + .corner_radius(CornerRadius::new_all(5.)) + .main_align(Alignment::Center) + .cross_align(Alignment::Center) + .background(if disabled { + colors::component_bg_disabled() + } else { + background.read().value() + }) + .border(ui::border_all_color(1., box_border)) + .maybe_child((checked || mark_opacity > 0.).then(|| { + rect().opacity(mark_opacity).scale(mark_scale).child( + Icon::new(IconType::Check) + .size(MARK_SIZE) + .color(if disabled { + colors::fg_secondary() + } else { + Color::WHITE + }), + ) + })), + ) + .maybe_child(self.label.as_ref().map(|text| { + label() + .text(text.to_string()) + .font_size(12.) + .color(if disabled { + colors::fg_secondary() + } else { + colors::fg_primary() + }) + })) + } +} diff --git a/packages/oneclient_app/src/components/mod.rs b/packages/oneclient_app/src/components/mod.rs index bdb4a564..d8d48193 100644 --- a/packages/oneclient_app/src/components/mod.rs +++ b/packages/oneclient_app/src/components/mod.rs @@ -3,6 +3,7 @@ mod active_cluster_panel; mod avatar; mod button; mod charts; +mod checkbox; mod cluster_landscape_art; mod cluster_update_popup; mod context_menu; @@ -46,6 +47,7 @@ pub use active_cluster_panel::ActiveClusterPanel; pub use avatar::Avatar; pub use button::{Button, ButtonSize, ButtonVariant, link_button, open_folder_button}; pub use charts::{BarChart, PieChart, ValueUnit, slice_color}; +pub use checkbox::checkbox_labeled; pub use cluster_landscape_art::ClusterLandscapeArt; pub use cluster_update_popup::ClusterUpdatePopup; pub use context_menu::ContextMenu; diff --git a/packages/oneclient_app/src/components/package_update_popup.rs b/packages/oneclient_app/src/components/package_update_popup.rs index 45ea8cbe..ce6b2e21 100644 --- a/packages/oneclient_app/src/components/package_update_popup.rs +++ b/packages/oneclient_app/src/components/package_update_popup.rs @@ -3,11 +3,12 @@ use std::collections::HashMap; use freya::prelude::*; +use oneclient_common::{PackageUpdateMode, Patch}; use oneclient_content::packages::{CachedPackageMeta, ProviderId}; -use oneclient_core::BrowserPackageUpdate; +use oneclient_core::{BrowserPackageUpdate, ProfileUpdate}; use oneclient_db::models::ClusterId; -use crate::components::{Button, Dropdown, Icon, IconType, OverlayPopup, ScrollArea}; +use crate::components::{Button, Dropdown, Icon, IconType, OverlayPopup, ScrollArea, checkbox_labeled}; use crate::hooks::{ package_meta_batch, use_dispatch, use_notifications_snapshot, use_package_meta_batch, }; @@ -63,6 +64,7 @@ impl Component for PackageUpdatePopup { // Absent entries default to Update so groups arriving mid-modal need no reconciliation let choices = use_state(HashMap::::new); + let mut dont_show_again = use_state(|| false); let groups = snapshot.package_updates.clone(); @@ -83,6 +85,8 @@ impl Component for PackageUpdatePopup { } let Some(groups) = groups.filter(|groups| !groups.is_empty()) else { + // The popup stays mounted, so the tick must not survive into the next prompt + dont_show_again.set_if_modified(false); return rect().into_element(); }; @@ -95,7 +99,7 @@ impl Component for PackageUpdatePopup { .width(Size::window_percent(100.)) .height(Size::window_percent(100.)) .center() - .child(dialog(&groups, &meta, dispatch, choices)), + .child(dialog(&groups, &meta, dispatch, choices, dont_show_again)), ) .into_element() } @@ -113,6 +117,7 @@ fn dialog( meta: &MetaMap, dispatch: crate::Actions, choices: State>, + dont_show_again: State, ) -> impl IntoElement { rect() .vertical() @@ -130,7 +135,7 @@ fn dialog( 0., Color::from_argb(150, 0, 0, 0), ))) - .child(content(groups, meta, dispatch, choices)) + .child(content(groups, meta, dispatch, choices, dont_show_again)) } fn content( @@ -138,10 +143,13 @@ fn content( meta: &MetaMap, dispatch: crate::Actions, choices: State>, + dont_show_again: State, ) -> impl IntoElement { let dismiss = dispatch.clone(); let proceed_dispatch = dispatch.clone(); + let dsa_dispatch = dispatch.clone(); let total: usize = groups.iter().map(|group| group.packages.len()).sum(); + let clusters: Vec = groups.iter().map(|group| group.cluster_id).collect(); let subtitle = match groups { [only] => format!( @@ -194,40 +202,73 @@ fn content( .horizontal() .width(Size::fill()) .cross_align(Alignment::Center) - .main_align(Alignment::End) + .main_align(Alignment::SpaceBetween) .spacing(8.) .child( - Button::new() - .ghost() - .on_press(move |_| dismiss.close_package_updates()) - .text("Cancel"), + rect() + .direction(Direction::Horizontal) + .main_align(Alignment::Start) + .cross_align(Alignment::Center) + .spacing(6.) + .child( + checkbox_labeled(dont_show_again, "Don't show again?") + ) ) .child( - Button::new() - .primary() - .on_press(move |_| { - let answers = choices.read(); - for update in &all { - match answers - .get(&row_key(update)) - .copied() - .unwrap_or_default() - { - RowChoice::Update => { - proceed_dispatch.apply_package_update(update.clone()); + rect() + .width(Size::fill()) + .direction(Direction::Horizontal) + .cross_align(Alignment::Center) + .main_align(Alignment::End) + .child( + Button::new() + .ghost() + .on_press(move |_| dismiss.close_package_updates()) + .text("Cancel"), + ) + .child( + Button::new() + .primary() + .on_press(move |_| { + if *dont_show_again.peek() { + // A cluster override outranks the global one, so the prompt + // is only silenced by writing the cluster that raised it + for cluster_id in &clusters { + dsa_dispatch.update_cluster_profile( + *cluster_id, + ProfileUpdate { + browser_update_mode: Patch::Set( + PackageUpdateMode::Skip, + ), + ..Default::default() + }, + ); + } } - RowChoice::Skip => {} - RowChoice::SkipVersion => proceed_dispatch - .skip_package_update( - update.cluster_id, - update.hash.clone(), - ), - } - } - proceed_dispatch.close_package_updates(); - }) - .child(Icon::new(IconType::DownloadCloud02).size(15.)) - .text("Proceed"), + + let answers = choices.read(); + for update in &all { + match answers + .get(&row_key(update)) + .copied() + .unwrap_or_default() + { + RowChoice::Update => { + proceed_dispatch.apply_package_update(update.clone()); + } + RowChoice::Skip => {} + RowChoice::SkipVersion => proceed_dispatch + .skip_package_update( + update.cluster_id, + update.hash.clone(), + ), + } + } + proceed_dispatch.close_package_updates(); + }) + .child(Icon::new(IconType::DownloadCloud02).size(15.)) + .text("Proceed"), + ) ), ) }