use freya::{ animation::{AnimNum, Ease, OnCreation, use_animation}, prelude::*, }; use oneclient_events::Level; use crate::{ components::{Button, ButtonVariant, Icon, IconType, OverlayPopup, ScrollArea, progress_track}, hooks::{use_dispatch, use_notifications_snapshot}, notifications::{InboxEntry, NotificationActionKind}, theme::colors, transfer::TransferStats, ui::{divider, relative_time}, utils::{format_durations, format_size}, }; #[derive(PartialEq)] pub struct NotificationCenter; impl Component for NotificationCenter { fn render(&self) -> impl IntoElement { let open = use_notifications_snapshot().center_open; let dispatch = use_dispatch(); if !open { return rect().into_element(); } OverlayPopup::new() .position(Position::new_global().top(72.).right(40.)) .on_close(move |()| dispatch.close_notification_center()) .child(NotificationPanel) .into_element() } } #[derive(PartialEq)] struct NotificationPanel; impl Component for NotificationPanel { fn render(&self) -> impl IntoElement { let inbox = use_notifications_snapshot().inbox; let intro = use_animation(|conf| { conf.on_creation(OnCreation::Run); AnimNum::new(0., 1.).time(200).ease(Ease::Out) }); let progress = intro.read().value(); let mut rows: Vec = Vec::new(); // Toast-only notices are ephemeral: never surface them in the center let visible: Vec = inbox .into_iter() .filter(|entry| !entry.toast_only) .collect(); if visible.is_empty() { rows.push( label() .text("No notifications") .font_size(13.) .color(colors::fg_secondary()) .into_element(), ); } else { let last = visible.len() - 1; for (i, entry) in visible.into_iter().enumerate() { let id = entry.id; rows.push(NotifEntryRow::new(entry, i != last).key(id).into_element()); } } let content = ScrollArea::new() .width(Size::fill()) .height(Size::flex(1.0)) .spacing(8.) .children(rows); rect() .vertical() .content(Content::Flex) .width(Size::px(368.)) .height(Size::px(480.)) .padding(Gaps::new_all(12.)) .spacing(8.) .opacity(progress) .margin(Gaps::new((1.0 - progress) * -8.0, 0., 0., 0.)) .background(colors::page_elevated().with_a(220)) .blur(12.) .corner_radius(CornerRadius::new_all(12.)) .border( Border::new() .fill(colors::component_border()) .width(1.) .alignment(BorderAlignment::Inner), ) .shadow(Shadow::from(( 0., 8., 32., 0., Color::from_argb(120, 0, 0, 0), ))) .child( label() .text("Notifications") .font_size(18.) .font_weight(FontWeight::MEDIUM) .a11y_role(AccessibilityRole::TitleBar), ) .child(divider()) .child(content) .child(divider()) .child(Footer) } } #[derive(PartialEq)] struct NotifEntryRow { entry: InboxEntry, show_divider: bool, key: DiffKey, } impl KeyExt for NotifEntryRow { fn write_key(&mut self) -> &mut DiffKey { &mut self.key } } impl NotifEntryRow { fn new(entry: InboxEntry, show_divider: bool) -> Self { Self { entry, show_divider, key: DiffKey::None, } } } impl Component for NotifEntryRow { fn render(&self) -> impl IntoElement { let entry = self.entry.clone(); let id = entry.id; let dispatch = use_dispatch(); let enter_dispatch = dispatch.clone(); let body_dispatch = dispatch.clone(); let click_dismissable = entry.click_dismissable(); let has_actions = !entry.actions.is_empty(); let mut hovered = use_state(|| false); let expanded = use_state(|| false); let content = rect() .horizontal() .width(Size::fill()) .spacing(13.) .cross_align(Alignment::Start) .padding(Gaps::new_all(8.)) .corner_radius(CornerRadius::new_all(8.)) .maybe(*hovered.read() && !has_actions, |el| { el.background(colors::ghost_overlay_hover()) }) .on_pointer_enter(move |_| { hovered.set(true); enter_dispatch.mark_notification_read(id); }) .on_pointer_leave(move |_| hovered.set(false)) .maybe(click_dismissable, |el| { el.on_press(move |_| dispatch.dismiss_notification(id)) }) .child( rect() .width(Size::px(32.)) .height(Size::px(32.)) .corner_radius(CornerRadius::new_all(6.)) .center() .child( Icon::new(icon_for(&entry)) .size(24.) .color(level_color(&entry.level)), ), ) .child(row_body(&entry, body_dispatch)); rect() .vertical() .width(Size::fill()) .spacing(8.) .child(content) .maybe(!entry.tasks.is_empty(), |el| { el.child(tasks_section(&entry, expanded)) }) .maybe(self.show_divider, |el| el.child(divider())) } } const TASK_LIMIT: usize = 12; fn tasks_section(entry: &InboxEntry, mut expanded: State) -> impl IntoElement { let is_expanded = *expanded.read(); let count = entry.tasks.len(); let mut section = rect() .vertical() .width(Size::fill()) .spacing(6.) .margin(Gaps::new(2., 0., 0., 44.)) .child( rect() .horizontal() .cross_align(Alignment::Center) .spacing(6.) .cursor(CursorIcon::Pointer) .on_press(move |_| { let now = !*expanded.peek(); expanded.set(now); }) .child(ChevronToggle { expanded: is_expanded, }) .child( label() .text(if is_expanded { "Hide tasks".to_string() } else { format!("Show {count} tasks") }) .font_size(11.) .color(colors::fg_secondary()), ), ); if is_expanded { let mut list = rect().vertical().width(Size::fill()).spacing(4.); for task in entry.tasks.iter().take(TASK_LIMIT) { list = list.child(task_row(task)); } if count > TASK_LIMIT { list = list.child( label() .text(format!("+{} more", count - TASK_LIMIT)) .font_size(10.) .color(colors::fg_secondary()), ); } section = section.child(list); } section.into_element() } fn task_row(task: &crate::notifications::TaskView) -> impl IntoElement { let percent = ((task.current as f32 / task.total.max(1) as f32) * 100.0).round() as u64; rect() .horizontal() .width(Size::fill()) .cross_align(Alignment::Center) .spacing(8.) .content(Content::Flex) .child(phase_badge(task.phase)) .child( label() .text(task.label.clone()) .font_size(11.) .max_lines(1) .width(Size::flex(1.0)) .color(colors::fg_primary()), ) .maybe(task.total_count > 1, |el| { el.child( label() .text(format!("{}/{}", task.done_count, task.total_count)) .font_size(10.) .color(colors::fg_secondary()), ) }) .child( label() .text(format!("{percent}%")) .font_size(10.) .color(colors::fg_secondary()), ) .into_element() } #[derive(PartialEq)] struct ChevronToggle { expanded: bool, } impl Component for ChevronToggle { fn render(&self) -> impl IntoElement { let expanded = self.expanded; let anim = use_animation(|_| AnimNum::new(0., 90.).time(160).ease(Ease::Out)); use_side_effect_with_deps(&expanded, move |&expanded| { let mut anim = anim; if expanded { anim.start(); } else { anim.reverse(); } }); let deg = anim.get().value(); rect().rotate(deg).child( Icon::new(IconType::ChevronRight) .size(14.) .color(colors::fg_secondary()), ) } } fn phase_badge(phase: &str) -> impl IntoElement { let color = match phase { "Verifying" => colors::code_warn(), "Extracting" => colors::success(), "Installing" => colors::brand(), _ => colors::brand(), }; rect() .center() .padding(Gaps::new_symmetric(1., 7.)) .corner_radius(CornerRadius::new_all(999.)) .background(color.with_a(40)) .child( label() .text(phase.to_string()) .font_size(9.) .font_weight(FontWeight::MEDIUM) .color(color), ) .into_element() } fn row_body(entry: &InboxEntry, dispatch: crate::Actions) -> impl IntoElement { rect() .vertical() .width(Size::flex(1.0)) .spacing(2.) .child( rect() .horizontal() .width(Size::fill()) .content(Content::Flex) .cross_align(Alignment::Start) .child( label() .text(entry.title.clone()) .font_size(14.) .font_weight(FontWeight::MEDIUM) .color(level_color(&entry.level)) .width(Size::flex(1.0)), ) .child( rect() .horizontal() .cross_align(Alignment::Center) .spacing(6.) .child( label() .text(relative_time(entry.created_at)) .font_size(10.) .color(colors::fg_secondary()), ) .maybe(!entry.read, |el| { el.child( rect() .width(Size::px(6.)) .height(Size::px(6.)) .corner_radius(CornerRadius::new_all(3.)) .background(colors::brand()), ) }), ), ) .child( label() .text(entry.body.clone()) .font_size(12.) .color(colors::fg_secondary()), ) .child(row_extra(entry, dispatch)) } fn row_extra(entry: &InboxEntry, dispatch: crate::Actions) -> impl IntoElement { if let Some((current, total)) = entry.progress.filter(|_| entry.is_loading) { let frac = (current as f32 / total.max(1) as f32).clamp(0.0, 1.0); let bar = progress_track( frac * 100.0, 6., colors::brand(), colors::brand().with_a(128), ); return rect() .vertical() .width(Size::fill()) .spacing(5.) .margin(Gaps::new(8., 0., 0., 0.)) .child(bar) .maybe_child(entry.transfer.map(transfer_footer)) .into_element(); } if !entry.actions.is_empty() { let id = entry.id; let dismiss_dispatch = dispatch.clone(); let mut row = rect() .horizontal() .width(Size::fill()) .content(Content::Flex) .cross_align(Alignment::Center) .spacing(8.) .margin(Gaps::new(8., 0., 0., 0.)) .child( Button::new() .small() .secondary() .width(Size::flex(1.0)) .text("Dismiss") .on_press(move |_| dismiss_dispatch.dismiss_notification(id)), ); for (i, action) in entry.actions.iter().enumerate() { let action_dispatch = dispatch.clone(); let kind = action.kind.clone(); row = row.child( Button::new() .small() .width(Size::flex(1.0)) .text(action.label.clone()) .variant(if i == 0 { ButtonVariant::Primary } else { ButtonVariant::Secondary }) .on_press(move |_| run_action(&action_dispatch, &kind)), ); } return row.into_element(); } rect().into_element() } fn transfer_footer(stats: TransferStats) -> Element { let speed = format!("{}/s", format_size(stats.speed_bps as u64)); let eta = stats .eta_secs .map(|secs| format!("{} left", format_durations(secs as i64))); rect() .horizontal() .width(Size::fill()) .content(Content::Flex) .cross_align(Alignment::Center) .main_align(Alignment::SpaceBetween) .child( label() .text(speed) .font_size(10.) .color(colors::fg_secondary()), ) .maybe_child(eta.map(|eta| { label() .text(eta) .font_size(10.) .color(colors::fg_secondary()) .into_element() })) .into_element() } fn run_action(dispatch: &crate::Actions, kind: &NotificationActionKind) { match kind { NotificationActionKind::OpenClusterUpdate(summaries) => { dispatch.open_cluster_update(summaries.clone()); } } } #[derive(PartialEq)] struct Footer; impl Component for Footer { fn render(&self) -> impl IntoElement { let dispatch = use_dispatch(); let is_empty = use_notifications_snapshot().inbox.is_empty(); rect() .horizontal() .width(Size::fill()) .content(Content::Flex) .cross_align(Alignment::Center) .child( Button::new() .ghost() .small() .disabled(is_empty) .on_press(move |_| dispatch.clear_notification_inbox()) .child(Icon::new(IconType::Trash01).size(16.).color(if is_empty { colors::fg_primary_disabled() } else { colors::fg_primary() })) .text("Clear all"), ) } } fn icon_for(entry: &InboxEntry) -> IconType { if let Some(icon) = entry.icon { return icon; } if entry.is_loading && entry.progress.is_some() { return IconType::DownloadCloud02; } match entry.level { Level::Error => IconType::AlertTriangle, Level::Info => IconType::InfoCircle, } } fn level_color(level: &Level) -> Color { match level { Level::Info => colors::fg_primary(), Level::Error => colors::danger(), } }