feat(ui): Placeholder UI for bundle update notification + modal
This commit is contained in:
parent
d0c97b4177
commit
21313b2093
12 changed files with 667 additions and 129 deletions
|
|
@ -6,7 +6,7 @@ use oneclient_core::packages::{ContentType, ProviderId};
|
|||
use oneclient_core::settings::{GameSettingsProfile, LauncherSettings, ProfileUpdate};
|
||||
use oneclient_db::models::ClusterId;
|
||||
|
||||
use crate::notifications::NotificationSpec;
|
||||
use crate::notifications::{ClusterUpdateSummary, NotificationSpec};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum BridgeCommand {
|
||||
|
|
@ -62,6 +62,8 @@ pub enum BridgeCommand {
|
|||
MarkNotificationRead(u64),
|
||||
DismissNotification(u64),
|
||||
AnswerPrompt(UserChoice),
|
||||
OpenClusterUpdate(ClusterUpdateSummary),
|
||||
CloseClusterUpdate,
|
||||
SendNotification { spec: NotificationSpec },
|
||||
SendTestProgress {
|
||||
current: u64,
|
||||
|
|
|
|||
|
|
@ -7,8 +7,10 @@ use oneclient_core::settings::store::{
|
|||
use oneclient_core::{ClusterManager, LauncherState};
|
||||
use tokio::sync::{mpsc, watch};
|
||||
|
||||
use crate::components::IconType;
|
||||
use crate::notifications::{
|
||||
InboxEntry, MESSAGE_TOAST_TTL, NotificationState, PendingPrompt, PendingPromptView,
|
||||
ClusterUpdateSummary, InboxEntry, MESSAGE_TOAST_TTL, NotificationAction, NotificationActionKind,
|
||||
NotificationSpec, NotificationState, PendingPrompt, PendingPromptView,
|
||||
};
|
||||
|
||||
use super::commands::BridgeCommand;
|
||||
|
|
@ -515,6 +517,13 @@ impl CoreBridgeRuntime {
|
|||
let _ = reply_tx.send(choice);
|
||||
}
|
||||
}
|
||||
BridgeCommand::OpenClusterUpdate(summary) => {
|
||||
notification_engine.open_cluster_update(summary);
|
||||
*notification_center_open = false;
|
||||
}
|
||||
BridgeCommand::CloseClusterUpdate => {
|
||||
notification_engine.close_cluster_update();
|
||||
}
|
||||
BridgeCommand::SendNotification { spec } => {
|
||||
notification_engine.push_custom(inbox, spec);
|
||||
}
|
||||
|
|
@ -637,24 +646,36 @@ impl CoreBridgeRuntime {
|
|||
}
|
||||
BridgeCommand::ApplyBundleUpdates { cluster_id } => {
|
||||
let state = LauncherState::get()?;
|
||||
oneclient_core::apply_bundle_updates(
|
||||
let result = oneclient_core::apply_bundle_updates(
|
||||
cluster_id,
|
||||
state.bundles.as_ref(),
|
||||
&state.services,
|
||||
)
|
||||
.await?;
|
||||
bump_clusters_generation(snapshots);
|
||||
if let Some(spec) =
|
||||
cluster_update_notification(cluster_id, &result, &state.services).await
|
||||
{
|
||||
notification_engine.push_custom(inbox, spec);
|
||||
}
|
||||
}
|
||||
BridgeCommand::SyncBundles => {
|
||||
let state = LauncherState::get()?;
|
||||
state.bundles.sync(&state.services).await?;
|
||||
oneclient_core::clusters::ensure_from_bundles(&state).await?;
|
||||
oneclient_core::bundles::sync_all_cluster_bundles(
|
||||
let changed = oneclient_core::bundles::sync_all_cluster_bundles(
|
||||
state.bundles.as_ref(),
|
||||
&state.services,
|
||||
)
|
||||
.await;
|
||||
bump_clusters_generation(snapshots);
|
||||
for (cluster_id, result) in &changed {
|
||||
if let Some(spec) =
|
||||
cluster_update_notification(*cluster_id, result, &state.services).await
|
||||
{
|
||||
notification_engine.push_custom(inbox, spec);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -662,6 +683,63 @@ impl CoreBridgeRuntime {
|
|||
}
|
||||
}
|
||||
|
||||
async fn cluster_update_notification(
|
||||
cluster_id: i64,
|
||||
result: &oneclient_core::ApplyBundleUpdatesResult,
|
||||
services: &oneclient_core::LauncherServices,
|
||||
) -> Option<NotificationSpec> {
|
||||
let updated: Vec<String> = result
|
||||
.updates_applied
|
||||
.iter()
|
||||
.map(|u| u.new_file.display_name())
|
||||
.collect();
|
||||
let added: Vec<String> = result
|
||||
.additions_applied
|
||||
.iter()
|
||||
.map(|a| a.new_file.display_name())
|
||||
.collect();
|
||||
let removed: Vec<String> = result
|
||||
.removals_applied
|
||||
.iter()
|
||||
.map(|r| r.package_id.clone())
|
||||
.collect();
|
||||
|
||||
if updated.is_empty() && added.is_empty() && removed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let cluster_name = PackageStore::get_cluster(cluster_id, services)
|
||||
.await
|
||||
.map(|c| c.name)
|
||||
.unwrap_or_else(|_| "Cluster".to_string());
|
||||
|
||||
let summary = ClusterUpdateSummary {
|
||||
cluster_id,
|
||||
cluster_name: cluster_name.clone(),
|
||||
updated,
|
||||
added,
|
||||
removed,
|
||||
};
|
||||
|
||||
let total = summary.total();
|
||||
let body = format!(
|
||||
"{total} package{} changed in {cluster_name}",
|
||||
if total == 1 { "" } else { "s" }
|
||||
);
|
||||
|
||||
Some(NotificationSpec {
|
||||
title: "Cluster updated".to_string(),
|
||||
body,
|
||||
level: NotificationLevel::Info,
|
||||
icon: Some(IconType::DownloadCloud02),
|
||||
progress: None,
|
||||
actions: vec![NotificationAction {
|
||||
label: "View changes".to_string(),
|
||||
kind: NotificationActionKind::OpenClusterUpdate(summary),
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
fn publish(tx: &watch::Sender<BridgeSnapshot>, snapshots: &BridgeSnapshot) {
|
||||
let _ = tx.send(snapshots.clone());
|
||||
}
|
||||
|
|
|
|||
267
packages/oneclient_app/src/components/cluster_update_popup.rs
Normal file
267
packages/oneclient_app/src/components/cluster_update_popup.rs
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
use freya::{prelude::*, router::RouterContext};
|
||||
|
||||
use crate::components::{Button, Icon, IconType, OverlayPopup, ScrollArea};
|
||||
use crate::hooks::{use_dispatch, use_notifications_snapshot};
|
||||
use crate::notifications::ClusterUpdateSummary;
|
||||
use crate::routes::Route;
|
||||
use crate::theme::colors;
|
||||
use crate::ui::border_all_color;
|
||||
|
||||
const CARD_BG: Color = Color::from_rgb(26, 34, 41);
|
||||
const DIALOG_W: f32 = 680.;
|
||||
const DIALOG_H: f32 = 400.;
|
||||
const BANNER_W: f32 = 300.;
|
||||
|
||||
#[derive(PartialEq)]
|
||||
pub struct ClusterUpdatePopup;
|
||||
|
||||
impl Component for ClusterUpdatePopup {
|
||||
fn render(&self) -> impl IntoElement {
|
||||
let snapshot = use_notifications_snapshot();
|
||||
let dispatch = use_dispatch();
|
||||
|
||||
let Some(summary) = snapshot.cluster_update.clone() else {
|
||||
return rect().into_element();
|
||||
};
|
||||
|
||||
let close = dispatch.clone();
|
||||
|
||||
OverlayPopup::new()
|
||||
.on_close(move |_| close.close_cluster_update())
|
||||
.child(
|
||||
rect()
|
||||
.width(Size::window_percent(100.))
|
||||
.height(Size::window_percent(100.))
|
||||
.center()
|
||||
.child(dialog(&summary, dispatch)),
|
||||
)
|
||||
.into_element()
|
||||
}
|
||||
}
|
||||
|
||||
fn dialog(summary: &ClusterUpdateSummary, dispatch: crate::BridgeDispatch) -> impl IntoElement {
|
||||
rect()
|
||||
.horizontal()
|
||||
.width(Size::px(DIALOG_W))
|
||||
.height(Size::px(DIALOG_H))
|
||||
.max_width(Size::window_percent(95.))
|
||||
.overflow(Overflow::Clip)
|
||||
.corner_radius(CornerRadius::new_all(16.))
|
||||
.background(CARD_BG)
|
||||
.border(border_all_color(1., colors::component_border()))
|
||||
.shadow(Shadow::from((0., 18., 52., 0., Color::from_argb(150, 0, 0, 0))))
|
||||
.child(banner(summary))
|
||||
.child(content(summary, dispatch))
|
||||
}
|
||||
|
||||
fn banner(summary: &ClusterUpdateSummary) -> impl IntoElement {
|
||||
rect()
|
||||
.vertical()
|
||||
.width(Size::px(BANNER_W))
|
||||
.height(Size::fill())
|
||||
.overflow(Overflow::Clip)
|
||||
.background(colors::brand().with_a(30))
|
||||
.main_align(Alignment::SpaceBetween)
|
||||
// watermark
|
||||
.child(
|
||||
rect()
|
||||
.width(Size::fill())
|
||||
.height(Size::flex(1.0))
|
||||
.center()
|
||||
.child(
|
||||
Icon::new(IconType::DownloadCloud02)
|
||||
.size(150.)
|
||||
.color(colors::brand().with_a(34)),
|
||||
),
|
||||
)
|
||||
// seated title block
|
||||
.child(
|
||||
rect()
|
||||
.vertical()
|
||||
.width(Size::fill())
|
||||
.spacing(10.)
|
||||
.padding(Gaps::new(24., 20., 20., 20.))
|
||||
.background(CARD_BG.with_a(170))
|
||||
.child(
|
||||
rect()
|
||||
.center()
|
||||
.padding(Gaps::new_symmetric(4., 10.))
|
||||
.corner_radius(CornerRadius::new_all(999.))
|
||||
.background(colors::brand())
|
||||
.child(
|
||||
rect()
|
||||
.horizontal()
|
||||
.cross_align(Alignment::Center)
|
||||
.spacing(5.)
|
||||
.child(Icon::new(IconType::RefreshCw01).size(12.).color(Color::WHITE))
|
||||
.child(
|
||||
label()
|
||||
.text("Updated")
|
||||
.font_size(11.)
|
||||
.font_weight(FontWeight::SEMI_BOLD)
|
||||
.color(Color::WHITE),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
label()
|
||||
.text(summary.cluster_name.clone())
|
||||
.font_size(23.)
|
||||
.font_weight(FontWeight::BOLD)
|
||||
.max_lines(2)
|
||||
.color(colors::fg_primary()),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn content(summary: &ClusterUpdateSummary, dispatch: crate::BridgeDispatch) -> impl IntoElement {
|
||||
let dismiss = dispatch.clone();
|
||||
let cluster_id = summary.cluster_id;
|
||||
let open = move |_| {
|
||||
dispatch.close_cluster_update();
|
||||
let _ = RouterContext::get().push(Route::ClusterOverview { cluster_id });
|
||||
};
|
||||
|
||||
let total = summary.total();
|
||||
|
||||
let mut sections = ScrollArea::new()
|
||||
.width(Size::fill())
|
||||
.height(Size::flex(1.0))
|
||||
.spacing(10.);
|
||||
|
||||
if !summary.updated.is_empty() {
|
||||
sections = sections.child(change_section(
|
||||
IconType::RefreshCw01,
|
||||
"Updated",
|
||||
colors::brand(),
|
||||
&summary.updated,
|
||||
));
|
||||
}
|
||||
if !summary.added.is_empty() {
|
||||
sections = sections.child(change_section(
|
||||
IconType::Plus,
|
||||
"Added",
|
||||
colors::success(),
|
||||
&summary.added,
|
||||
));
|
||||
}
|
||||
if !summary.removed.is_empty() {
|
||||
sections = sections.child(change_section(
|
||||
IconType::Trash01,
|
||||
"Removed",
|
||||
colors::danger(),
|
||||
&summary.removed,
|
||||
));
|
||||
}
|
||||
|
||||
rect()
|
||||
.vertical()
|
||||
.width(Size::flex(1.0))
|
||||
.height(Size::fill())
|
||||
.content(Content::Flex)
|
||||
.padding(Gaps::new_all(22.))
|
||||
.spacing(14.)
|
||||
.child(
|
||||
rect()
|
||||
.vertical()
|
||||
.width(Size::fill())
|
||||
.spacing(3.)
|
||||
.child(
|
||||
label()
|
||||
.text("Changes applied")
|
||||
.font_size(17.)
|
||||
.font_weight(FontWeight::SEMI_BOLD)
|
||||
.color(colors::fg_primary()),
|
||||
)
|
||||
.child(
|
||||
label()
|
||||
.text(format!(
|
||||
"{total} change{} synced to this cluster.",
|
||||
if total == 1 { "" } else { "s" }
|
||||
))
|
||||
.font_size(12.5)
|
||||
.max_lines(2)
|
||||
.color(colors::fg_secondary()),
|
||||
),
|
||||
)
|
||||
.child(sections)
|
||||
.child(
|
||||
rect()
|
||||
.horizontal()
|
||||
.width(Size::fill())
|
||||
.cross_align(Alignment::Center)
|
||||
.main_align(Alignment::SpaceBetween)
|
||||
.spacing(8.)
|
||||
.child(
|
||||
Button::new()
|
||||
.ghost()
|
||||
.on_press(move |_| dismiss.close_cluster_update())
|
||||
.text("Dismiss"),
|
||||
)
|
||||
.child(
|
||||
Button::new()
|
||||
.primary()
|
||||
.on_press(open)
|
||||
.text("Open cluster")
|
||||
.child(Icon::new(IconType::ArrowRight).size(15.)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn change_section(
|
||||
icon: IconType,
|
||||
heading: &str,
|
||||
accent: Color,
|
||||
items: &[String],
|
||||
) -> impl IntoElement {
|
||||
let mut list = rect().vertical().width(Size::fill()).spacing(1.);
|
||||
for item in items {
|
||||
list = list.child(
|
||||
rect()
|
||||
.horizontal()
|
||||
.width(Size::fill())
|
||||
.cross_align(Alignment::Center)
|
||||
.spacing(9.)
|
||||
.padding(Gaps::new_symmetric(4., 4.))
|
||||
.child(
|
||||
rect()
|
||||
.width(Size::px(5.))
|
||||
.height(Size::px(5.))
|
||||
.corner_radius(CornerRadius::new_all(3.))
|
||||
.background(accent),
|
||||
)
|
||||
.child(
|
||||
label()
|
||||
.text(item.clone())
|
||||
.font_size(12.5)
|
||||
.max_lines(1)
|
||||
.width(Size::flex(1.0))
|
||||
.color(colors::fg_primary()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
rect()
|
||||
.vertical()
|
||||
.width(Size::fill())
|
||||
.spacing(5.)
|
||||
.padding(Gaps::new(9., 11., 11., 11.))
|
||||
.corner_radius(CornerRadius::new_all(10.))
|
||||
.background(colors::page_elevated().with_a(140))
|
||||
.border(border_all_color(1., colors::component_border().with_a(120)))
|
||||
.child(
|
||||
rect()
|
||||
.horizontal()
|
||||
.cross_align(Alignment::Center)
|
||||
.spacing(7.)
|
||||
.child(Icon::new(icon).size(14.).color(accent))
|
||||
.child(
|
||||
label()
|
||||
.text(format!("{heading} · {}", items.len()))
|
||||
.font_size(12.)
|
||||
.font_weight(FontWeight::SEMI_BOLD)
|
||||
.color(accent),
|
||||
),
|
||||
)
|
||||
.child(list)
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ mod avatar;
|
|||
mod button;
|
||||
mod charts;
|
||||
mod cluster_landscape_art;
|
||||
mod cluster_update_popup;
|
||||
mod context_menu;
|
||||
mod dropdown;
|
||||
mod dynamic_art;
|
||||
|
|
@ -36,6 +37,7 @@ pub use avatar::Avatar;
|
|||
pub use button::{Button, ButtonSize, ButtonVariant, link_button, open_folder_button};
|
||||
pub use charts::{BarChart, ValueUnit};
|
||||
pub use cluster_landscape_art::ClusterLandscapeArt;
|
||||
pub use cluster_update_popup::ClusterUpdatePopup;
|
||||
pub use context_menu::ContextMenu;
|
||||
pub use dropdown::Dropdown;
|
||||
pub use dynamic_art::DynamicArt;
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ use crate::{
|
|||
Route,
|
||||
components::{Button, ButtonVariant, Icon, IconType, OverlayPopup, ScrollArea},
|
||||
hooks::{use_dispatch, use_notifications_snapshot},
|
||||
notifications::InboxEntry,
|
||||
notifications::{InboxEntry, NotificationActionKind},
|
||||
theme::colors,
|
||||
};
|
||||
|
||||
|
|
@ -139,18 +139,13 @@ impl Component for NotifEntryRow {
|
|||
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 intro = use_animation(|conf| {
|
||||
conf.on_creation(OnCreation::Run);
|
||||
AnimNum::new(0., 1.).time(240).ease(Ease::Out)
|
||||
});
|
||||
|
||||
let progress = intro.read().value();
|
||||
let slide = (1.0 - progress) * 16.0;
|
||||
|
||||
let content = rect()
|
||||
.horizontal()
|
||||
.width(Size::fill())
|
||||
|
|
@ -158,9 +153,7 @@ impl Component for NotifEntryRow {
|
|||
.cross_align(Alignment::Start)
|
||||
.padding(Gaps::new_all(8.))
|
||||
.corner_radius(CornerRadius::new_all(8.))
|
||||
.opacity(progress)
|
||||
.margin(Gaps::new(0., 0., 0., slide))
|
||||
.maybe(*hovered.read(), |el| {
|
||||
.maybe(*hovered.read() && !has_actions, |el| {
|
||||
el.background(colors::ghost_overlay_hover())
|
||||
})
|
||||
.on_pointer_enter(move |_| {
|
||||
|
|
@ -183,7 +176,7 @@ impl Component for NotifEntryRow {
|
|||
.color(level_color(&entry.level)),
|
||||
),
|
||||
)
|
||||
.child(row_body(&entry));
|
||||
.child(row_body(&entry, body_dispatch));
|
||||
|
||||
rect()
|
||||
.vertical()
|
||||
|
|
@ -307,7 +300,7 @@ fn phase_badge(phase: &str) -> impl IntoElement {
|
|||
.into_element()
|
||||
}
|
||||
|
||||
fn row_body(entry: &InboxEntry) -> impl IntoElement {
|
||||
fn row_body(entry: &InboxEntry, dispatch: crate::BridgeDispatch) -> impl IntoElement {
|
||||
rect()
|
||||
.vertical()
|
||||
.width(Size::flex(1.0))
|
||||
|
|
@ -354,10 +347,10 @@ fn row_body(entry: &InboxEntry) -> impl IntoElement {
|
|||
.font_size(12.)
|
||||
.color(colors::fg_secondary()),
|
||||
)
|
||||
.child(row_extra(entry))
|
||||
.child(row_extra(entry, dispatch))
|
||||
}
|
||||
|
||||
fn row_extra(entry: &InboxEntry) -> impl IntoElement {
|
||||
fn row_extra(entry: &InboxEntry, dispatch: crate::BridgeDispatch) -> 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);
|
||||
return rect()
|
||||
|
|
@ -377,17 +370,40 @@ fn row_extra(entry: &InboxEntry) -> impl IntoElement {
|
|||
}
|
||||
|
||||
if !entry.actions.is_empty() {
|
||||
let id = entry.id;
|
||||
let dismiss_dispatch = dispatch.clone();
|
||||
|
||||
let mut row = rect()
|
||||
.horizontal()
|
||||
.spacing(16.)
|
||||
.margin(Gaps::new(8., 0., 0., 0.));
|
||||
.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, text) in entry.actions.iter().enumerate() {
|
||||
row = row.child(Button::new().small().text(text.clone()).variant(if i == 0 {
|
||||
ButtonVariant::Primary
|
||||
} else {
|
||||
ButtonVariant::Secondary
|
||||
}));
|
||||
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();
|
||||
|
|
@ -396,6 +412,14 @@ fn row_extra(entry: &InboxEntry) -> impl IntoElement {
|
|||
rect().into_element()
|
||||
}
|
||||
|
||||
fn run_action(dispatch: &crate::BridgeDispatch, kind: &NotificationActionKind) {
|
||||
match kind {
|
||||
NotificationActionKind::OpenClusterUpdate(summary) => {
|
||||
dispatch.open_cluster_update(summary.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn divider() -> impl IntoElement {
|
||||
rect()
|
||||
.width(Size::fill())
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ use oneclient_core::notification::NotificationLevel;
|
|||
use crate::{
|
||||
components::{Button, ButtonSize, Icon, IconType},
|
||||
hooks::{use_dispatch, use_notifications_snapshot},
|
||||
notifications::InboxEntry,
|
||||
notifications::{InboxEntry, NotificationActionKind},
|
||||
theme::colors,
|
||||
};
|
||||
|
||||
|
|
@ -263,11 +263,7 @@ fn message_card(
|
|||
}
|
||||
|
||||
fn action_card(entry: &InboxEntry, dispatch: crate::BridgeDispatch, id: u64, ttl_pct: f32) -> Rect {
|
||||
let review_label = entry
|
||||
.actions
|
||||
.first()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "Review".to_string());
|
||||
let action = entry.actions.first().cloned();
|
||||
|
||||
toast_shell(entry)
|
||||
.child(
|
||||
|
|
@ -287,60 +283,60 @@ fn action_card(entry: &InboxEntry, dispatch: crate::BridgeDispatch, id: u64, ttl
|
|||
.child(header(entry)),
|
||||
)
|
||||
.child(divider())
|
||||
.child(action_row(review_label, dispatch, id)),
|
||||
.child(action_row(action, dispatch, id)),
|
||||
)
|
||||
.child(ttl_bar(entry, ttl_pct))
|
||||
}
|
||||
|
||||
fn action_row(review_label: String, dispatch: crate::BridgeDispatch, id: u64) -> impl IntoElement {
|
||||
fn action_row(
|
||||
action: Option<crate::notifications::NotificationAction>,
|
||||
dispatch: crate::BridgeDispatch,
|
||||
id: u64,
|
||||
) -> impl IntoElement {
|
||||
let dismiss = dispatch.clone();
|
||||
let review_label = action
|
||||
.as_ref()
|
||||
.map(|a| a.label.clone())
|
||||
.unwrap_or_else(|| "Review".to_string());
|
||||
let review_kind = action.map(|a| a.kind);
|
||||
|
||||
rect()
|
||||
.horizontal()
|
||||
.width(Size::fill())
|
||||
.content(Content::Flex)
|
||||
.cross_align(Alignment::Center)
|
||||
.padding(Gaps::new(0., 30., 0., 30.))
|
||||
.spacing(8.)
|
||||
.child(
|
||||
rect()
|
||||
.horizontal()
|
||||
Button::new()
|
||||
.small()
|
||||
.secondary()
|
||||
.width(Size::flex(1.0))
|
||||
.cross_align(Alignment::Center)
|
||||
.spacing(8.)
|
||||
.on_press(move |_| dispatch.mark_notification_read(id))
|
||||
.child(
|
||||
Icon::new(IconType::Eye)
|
||||
.size(18.)
|
||||
.color(colors::toast_action()),
|
||||
)
|
||||
.child(
|
||||
label()
|
||||
.text(review_label)
|
||||
.font_size(12.)
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.color(colors::toast_action()),
|
||||
),
|
||||
.on_press(move |_| dismiss.dismiss_toast(id))
|
||||
.child(Icon::new(IconType::XClose).size(16.))
|
||||
.text("Dismiss"),
|
||||
)
|
||||
.child(
|
||||
rect()
|
||||
.horizontal()
|
||||
.cross_align(Alignment::Center)
|
||||
.spacing(8.)
|
||||
.on_press(move |_| dismiss.dismiss_toast(id))
|
||||
.child(
|
||||
Icon::new(IconType::XClose)
|
||||
.size(18.)
|
||||
.color(colors::toast_action()),
|
||||
)
|
||||
.child(
|
||||
label()
|
||||
.text("Dismiss")
|
||||
.font_size(12.)
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.color(colors::toast_action()),
|
||||
),
|
||||
Button::new()
|
||||
.small()
|
||||
.primary()
|
||||
.width(Size::flex(1.0))
|
||||
.on_press(move |_| match &review_kind {
|
||||
Some(kind) => run_action(&dispatch, kind),
|
||||
None => dispatch.mark_notification_read(id),
|
||||
})
|
||||
.child(Icon::new(IconType::Eye).size(16.))
|
||||
.text(review_label),
|
||||
)
|
||||
}
|
||||
|
||||
fn run_action(dispatch: &crate::BridgeDispatch, kind: &NotificationActionKind) {
|
||||
match kind {
|
||||
NotificationActionKind::OpenClusterUpdate(summary) => {
|
||||
dispatch.open_cluster_update(summary.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn progress_card(entry: &InboxEntry) -> Rect {
|
||||
let frac = entry
|
||||
.progress
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use oneclient_db::models::ClusterId;
|
|||
|
||||
use crate::bridge::{BridgeCommand, OneClientBridge};
|
||||
use crate::components::IconType;
|
||||
use crate::notifications::NotificationSpec;
|
||||
use crate::notifications::{ClusterUpdateSummary, NotificationAction, NotificationSpec};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct BridgeDispatch {
|
||||
|
|
@ -184,6 +184,14 @@ impl BridgeDispatch {
|
|||
self.send(BridgeCommand::AnswerPrompt(choice));
|
||||
}
|
||||
|
||||
pub fn open_cluster_update(&self, summary: ClusterUpdateSummary) {
|
||||
self.send(BridgeCommand::OpenClusterUpdate(summary));
|
||||
}
|
||||
|
||||
pub fn close_cluster_update(&self) {
|
||||
self.send(BridgeCommand::CloseClusterUpdate);
|
||||
}
|
||||
|
||||
pub fn notify(&self, title: impl Into<String>) -> NotificationBuilder {
|
||||
NotificationBuilder {
|
||||
dispatch: self.clone(),
|
||||
|
|
@ -299,13 +307,13 @@ impl NotificationBuilder {
|
|||
self
|
||||
}
|
||||
|
||||
pub fn action(mut self, label: impl Into<String>) -> Self {
|
||||
self.spec.actions.push(label.into());
|
||||
pub fn action(mut self, action: NotificationAction) -> Self {
|
||||
self.spec.actions.push(action);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn actions(mut self, actions: impl IntoIterator<Item = impl Into<String>>) -> Self {
|
||||
self.spec.actions = actions.into_iter().map(Into::into).collect();
|
||||
pub fn actions(mut self, actions: impl IntoIterator<Item = NotificationAction>) -> Self {
|
||||
self.spec.actions = actions.into_iter().collect();
|
||||
self
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ use freya::prelude::*;
|
|||
use freya::router::*;
|
||||
|
||||
use crate::components::{
|
||||
AccountSwitcher, NotificationCenter, StatusBar, Toasts, UpdatePromptOverlay,
|
||||
AccountSwitcher, ClusterUpdatePopup, NotificationCenter, StatusBar, Toasts, UpdatePromptOverlay,
|
||||
};
|
||||
use crate::routes::Route;
|
||||
use crate::theme;
|
||||
|
|
@ -31,6 +31,7 @@ impl Component for RootLayout {
|
|||
.child(AccountSwitcher)
|
||||
.child(Toasts)
|
||||
.child(UpdatePromptOverlay)
|
||||
.child(ClusterUpdatePopup)
|
||||
.child(StatusBar)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ use std::time::{Duration, Instant};
|
|||
use oneclient_core::notification::{
|
||||
GroupedProgressEvent, Notification, NotificationLevel, PromptKind, UserChoice,
|
||||
};
|
||||
use oneclient_db::models::ClusterId;
|
||||
use tokio::sync::oneshot;
|
||||
use uuid::Uuid;
|
||||
|
||||
|
|
@ -11,6 +12,32 @@ use crate::components::IconType;
|
|||
|
||||
pub const MESSAGE_TOAST_TTL: Duration = Duration::from_secs(5);
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct NotificationAction {
|
||||
pub label: String,
|
||||
pub kind: NotificationActionKind,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum NotificationActionKind {
|
||||
OpenClusterUpdate(ClusterUpdateSummary),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ClusterUpdateSummary {
|
||||
pub cluster_id: ClusterId,
|
||||
pub cluster_name: String,
|
||||
pub updated: Vec<String>,
|
||||
pub added: Vec<String>,
|
||||
pub removed: Vec<String>,
|
||||
}
|
||||
|
||||
impl ClusterUpdateSummary {
|
||||
pub fn total(&self) -> usize {
|
||||
self.updated.len() + self.added.len() + self.removed.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct InboxEntry {
|
||||
pub level: NotificationLevel,
|
||||
|
|
@ -22,7 +49,7 @@ pub struct InboxEntry {
|
|||
pub is_loading: bool,
|
||||
pub read: bool,
|
||||
pub created_at: Instant,
|
||||
pub actions: Vec<String>,
|
||||
pub actions: Vec<NotificationAction>,
|
||||
pub tasks: Vec<TaskView>,
|
||||
}
|
||||
|
||||
|
|
@ -41,7 +68,7 @@ pub struct NotificationSpec {
|
|||
pub level: NotificationLevel,
|
||||
pub icon: Option<IconType>,
|
||||
pub progress: Option<(u64, u64)>,
|
||||
pub actions: Vec<String>,
|
||||
pub actions: Vec<NotificationAction>,
|
||||
}
|
||||
|
||||
impl InboxEntry {
|
||||
|
|
@ -89,6 +116,7 @@ pub struct NotificationSnapshot {
|
|||
pub inbox: Vec<InboxEntry>,
|
||||
pub center_open: bool,
|
||||
pub pending_prompt: Option<PendingPromptView>,
|
||||
pub cluster_update: Option<ClusterUpdateSummary>,
|
||||
pub active_toast_entry_ids: Vec<u64>,
|
||||
}
|
||||
|
||||
|
|
@ -106,6 +134,7 @@ pub struct NotificationState {
|
|||
grouped_entries: HashMap<Uuid, u64>,
|
||||
grouped_tasks: HashMap<Uuid, GroupedTasks>,
|
||||
pending_timers: Vec<ToastDismissTimer>,
|
||||
cluster_update: Option<ClusterUpdateSummary>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
|
|
@ -145,10 +174,19 @@ impl NotificationState {
|
|||
inbox: inbox.to_vec(),
|
||||
center_open,
|
||||
pending_prompt,
|
||||
cluster_update: self.cluster_update.clone(),
|
||||
active_toast_entry_ids: self.active_toasts.iter().map(|t| t.entry_id).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_cluster_update(&mut self, summary: ClusterUpdateSummary) {
|
||||
self.cluster_update = Some(summary);
|
||||
}
|
||||
|
||||
pub fn close_cluster_update(&mut self) {
|
||||
self.cluster_update = None;
|
||||
}
|
||||
|
||||
pub fn unread_count(inbox: &[InboxEntry]) -> usize {
|
||||
inbox.iter().filter(|entry| !entry.read).count()
|
||||
}
|
||||
|
|
@ -283,7 +321,7 @@ impl NotificationState {
|
|||
if inbox
|
||||
.iter()
|
||||
.find(|e| e.id == entry_id)
|
||||
.is_some_and(|e| e.click_dismissable())
|
||||
.is_some_and(|e| e.dismissable())
|
||||
{
|
||||
self.forget_entry(inbox, entry_id);
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ use freya::router::RouterContext;
|
|||
|
||||
use crate::components::{Button, Icon, IconType, TextInput, toggle};
|
||||
use crate::hooks::use_dispatch;
|
||||
use crate::notifications::{ClusterUpdateSummary, NotificationAction, NotificationActionKind};
|
||||
use crate::routes::Route;
|
||||
use crate::theme::colors;
|
||||
|
||||
|
|
@ -20,48 +21,58 @@ impl Component for Debug {
|
|||
.vertical()
|
||||
.width(Size::fill())
|
||||
.height(Size::fill())
|
||||
.overflow(Overflow::Clip)
|
||||
.padding(40.)
|
||||
.spacing(8.)
|
||||
.child(
|
||||
rect()
|
||||
.vertical()
|
||||
ScrollView::new()
|
||||
.child(
|
||||
label()
|
||||
.text("Debug")
|
||||
.font_size(32.)
|
||||
.font_weight(FontWeight::BOLD)
|
||||
.color(colors::fg_primary()),
|
||||
rect()
|
||||
.vertical()
|
||||
.child(
|
||||
label()
|
||||
.text("Debug")
|
||||
.font_size(32.)
|
||||
.font_weight(FontWeight::BOLD)
|
||||
.color(colors::fg_primary()),
|
||||
)
|
||||
.child(
|
||||
label()
|
||||
.text("The end user won't really be looking at this page.")
|
||||
.font_size(13.)
|
||||
.color(colors::fg_secondary()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
label()
|
||||
.text("The end user won't really be looking at this page.")
|
||||
.font_size(13.)
|
||||
.color(colors::fg_secondary()),
|
||||
),
|
||||
.child(divider())
|
||||
.child(section(
|
||||
"Settings",
|
||||
vec![
|
||||
toggle_row("Log Debug Info", log_debug_info),
|
||||
toggle_row("Show Dev stuff", show_dev_stuff),
|
||||
toggle_row("Seen Onboarding", seen_onboarding),
|
||||
toggle_row("Use Grid On Mods List", use_grid_on_mods_list),
|
||||
],
|
||||
))
|
||||
.child(divider())
|
||||
.child(section(
|
||||
"Toast Controller",
|
||||
vec![ToastController.into_element()],
|
||||
))
|
||||
.child(divider())
|
||||
.child(section(
|
||||
"Cluster Update Simulator",
|
||||
vec![ClusterUpdateSimulator.into_element()],
|
||||
))
|
||||
.child(divider())
|
||||
.child(section(
|
||||
"Other",
|
||||
vec![action_row(vec![
|
||||
("Open Dev Tools", IconType::CodeSnippet02),
|
||||
("Open Onboarding", IconType::Rocket02),
|
||||
("Open Launcher Data", IconType::Folder),
|
||||
("Log Running Processes", IconType::Terminal),
|
||||
])],
|
||||
)),
|
||||
)
|
||||
.child(divider())
|
||||
.child(section(
|
||||
"Settings",
|
||||
vec![
|
||||
toggle_row("Log Debug Info", log_debug_info),
|
||||
toggle_row("Show Dev stuff", show_dev_stuff),
|
||||
toggle_row("Seen Onboarding", seen_onboarding),
|
||||
toggle_row("Use Grid On Mods List", use_grid_on_mods_list),
|
||||
],
|
||||
))
|
||||
.child(divider())
|
||||
.child(section("Toast Controller", vec![ToastController.into_element()]))
|
||||
.child(divider())
|
||||
.child(section(
|
||||
"Other",
|
||||
vec![action_row(vec![
|
||||
("Open Dev Tools", IconType::CodeSnippet02),
|
||||
("Open Onboarding", IconType::Rocket02),
|
||||
("Open Launcher Data", IconType::Folder),
|
||||
("Log Running Processes", IconType::Terminal),
|
||||
])],
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -123,7 +134,8 @@ impl Component for ToastController {
|
|||
.child(Icon::new(IconType::AlertTriangle).size(16.))
|
||||
.text("Send Error")
|
||||
.on_press(move |_| {
|
||||
error.notify(title.read().clone())
|
||||
error
|
||||
.notify(title.read().clone())
|
||||
.body(body.read().clone())
|
||||
.error()
|
||||
.send();
|
||||
|
|
@ -154,6 +166,101 @@ impl Component for ToastController {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
struct ClusterUpdateSimulator;
|
||||
|
||||
impl Component for ClusterUpdateSimulator {
|
||||
fn render(&self) -> impl IntoElement {
|
||||
let dispatch = use_dispatch();
|
||||
let cluster_id = use_state(|| "1".to_string());
|
||||
let cluster_name = use_state(|| "PolyBlock".to_string());
|
||||
let updated = use_state(|| "Sodium 0.5 → 0.6, Iris 1.7 → 1.8".to_string());
|
||||
let added = use_state(|| "Lithium".to_string());
|
||||
let removed = use_state(|| "OptiFine".to_string());
|
||||
|
||||
let simulate = dispatch;
|
||||
|
||||
rect()
|
||||
.vertical()
|
||||
.width(Size::fill())
|
||||
.spacing(10.)
|
||||
.child(
|
||||
rect()
|
||||
.horizontal()
|
||||
.width(Size::fill())
|
||||
.spacing(12.)
|
||||
.child(
|
||||
rect()
|
||||
.width(Size::px(120.))
|
||||
.child(TextInput::new(cluster_id).placeholder("Cluster ID")),
|
||||
)
|
||||
.child(
|
||||
rect()
|
||||
.width(Size::flex(1.0))
|
||||
.child(TextInput::new(cluster_name).placeholder("Cluster name")),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
rect()
|
||||
.horizontal()
|
||||
.width(Size::fill())
|
||||
.spacing(12.)
|
||||
.child(
|
||||
rect().width(Size::flex(1.0)).child(
|
||||
TextInput::new(updated).placeholder("Updated (comma separated)"),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
rect()
|
||||
.width(Size::flex(1.0))
|
||||
.child(TextInput::new(added).placeholder("Added (comma separated)")),
|
||||
)
|
||||
.child(
|
||||
rect().width(Size::flex(1.0)).child(
|
||||
TextInput::new(removed).placeholder("Removed (comma separated)"),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Button::new()
|
||||
.primary()
|
||||
.child(Icon::new(IconType::DownloadCloud02).size(16.))
|
||||
.text("Simulate Cluster Update")
|
||||
.on_press(move |_| {
|
||||
let name = cluster_name.read().clone();
|
||||
let summary = ClusterUpdateSummary {
|
||||
cluster_id: cluster_id.read().trim().parse().unwrap_or(1),
|
||||
cluster_name: name.clone(),
|
||||
updated: split_csv(&updated.read()),
|
||||
added: split_csv(&added.read()),
|
||||
removed: split_csv(&removed.read()),
|
||||
};
|
||||
let total = summary.total();
|
||||
simulate
|
||||
.notify("Cluster updated")
|
||||
.body(format!(
|
||||
"{total} package{} changed in {name}",
|
||||
if total == 1 { "" } else { "s" }
|
||||
))
|
||||
.icon(IconType::DownloadCloud02)
|
||||
.action(NotificationAction {
|
||||
label: "View changes".to_string(),
|
||||
kind: NotificationActionKind::OpenClusterUpdate(summary),
|
||||
})
|
||||
.send();
|
||||
}),
|
||||
)
|
||||
.into_element()
|
||||
}
|
||||
}
|
||||
|
||||
fn split_csv(raw: &str) -> Vec<String> {
|
||||
raw.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn divider() -> impl IntoElement {
|
||||
rect()
|
||||
.width(Size::fill())
|
||||
|
|
@ -230,10 +337,7 @@ fn toggle_row(title: &'static str, on: State<bool>) -> Element {
|
|||
}
|
||||
|
||||
fn action_row(buttons: Vec<(&'static str, IconType)>) -> Element {
|
||||
let mut row = rect()
|
||||
.horizontal()
|
||||
.width(Size::fill())
|
||||
.spacing(12.);
|
||||
let mut row = rect().horizontal().width(Size::fill()).spacing(12.);
|
||||
|
||||
for (text, icon) in buttons {
|
||||
let mut button = Button::new()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use crate::bundles::manager::BundlesManager;
|
||||
use crate::bundles::ApplyBundleUpdatesResult;
|
||||
use crate::bundles::updates::apply_bundle_updates_for_all_clusters;
|
||||
use crate::state::LauncherServices;
|
||||
|
||||
|
|
@ -10,10 +11,18 @@ pub fn is_bundle_syncing() -> bool {
|
|||
BUNDLE_SYNCING.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub async fn sync_all_cluster_bundles(bundles: &BundlesManager, services: &LauncherServices) {
|
||||
pub async fn sync_all_cluster_bundles(
|
||||
bundles: &BundlesManager,
|
||||
services: &LauncherServices,
|
||||
) -> Vec<(i64, ApplyBundleUpdatesResult)> {
|
||||
BUNDLE_SYNCING.store(true, Ordering::Relaxed);
|
||||
if let Err(err) = apply_bundle_updates_for_all_clusters(bundles, services).await {
|
||||
tracing::error!("failed to apply bundle updates for clusters: {err:#}");
|
||||
}
|
||||
let changed = match apply_bundle_updates_for_all_clusters(bundles, services).await {
|
||||
Ok(changed) => changed,
|
||||
Err(err) => {
|
||||
tracing::error!("failed to apply bundle updates for clusters: {err:#}");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
BUNDLE_SYNCING.store(false, Ordering::Relaxed);
|
||||
changed
|
||||
}
|
||||
|
|
|
|||
|
|
@ -593,17 +593,26 @@ pub async fn get_bundles_with_update_status(
|
|||
pub async fn apply_bundle_updates_for_all_clusters(
|
||||
bundles: &BundlesManager,
|
||||
services: &LauncherServices,
|
||||
) -> LauncherResult<()> {
|
||||
) -> LauncherResult<Vec<(i64, ApplyBundleUpdatesResult)>> {
|
||||
let mut changed = Vec::new();
|
||||
for cluster in cluster_dao::list_all(&services.db).await? {
|
||||
if let Err(err) = apply_bundle_updates(cluster.id, bundles, services).await {
|
||||
tracing::warn!(
|
||||
match apply_bundle_updates(cluster.id, bundles, services).await {
|
||||
Ok(result) => {
|
||||
if !result.updates_applied.is_empty()
|
||||
|| !result.additions_applied.is_empty()
|
||||
|| !result.removals_applied.is_empty()
|
||||
{
|
||||
changed.push((cluster.id, result));
|
||||
}
|
||||
}
|
||||
Err(err) => tracing::warn!(
|
||||
cluster_id = cluster.id,
|
||||
error = %err,
|
||||
"bundle update apply failed for cluster"
|
||||
);
|
||||
),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
fn bundle_package_key(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue