diff --git a/packages/oneclient_app/src/bridge/commands.rs b/packages/oneclient_app/src/bridge/commands.rs index 5bcc310f..8e673e7d 100644 --- a/packages/oneclient_app/src/bridge/commands.rs +++ b/packages/oneclient_app/src/bridge/commands.rs @@ -54,6 +54,8 @@ pub enum BridgeCommand { ToggleNotificationCenter, CloseNotificationCenter, + ToggleAccountSwitcher, + CloseAccountSwitcher, ClearNotificationInbox, DismissToast(u64), BumpToast(u64), diff --git a/packages/oneclient_app/src/bridge/runtime.rs b/packages/oneclient_app/src/bridge/runtime.rs index 595da137..03e4c700 100644 --- a/packages/oneclient_app/src/bridge/runtime.rs +++ b/packages/oneclient_app/src/bridge/runtime.rs @@ -1,4 +1,4 @@ -use oneclient_core::notification::{NotificationLevel, Notification, NotificationService}; +use oneclient_core::notification::{Notification, NotificationLevel, NotificationService}; use oneclient_core::packages::PackageStore; use oneclient_core::settings::store::{ self, create_profile_from_global, create_settings_profile, delete_named_profile, @@ -402,18 +402,24 @@ impl CoreBridgeRuntime { bump_clusters_generation(snapshots); } - BridgeCommand::ImportLauncher { source, folder_name, target } => { + BridgeCommand::ImportLauncher { + source, + folder_name, + target, + } => { let state = LauncherState::get()?; tokio::spawn(async move { let notifier = state.services.notifier.clone(); - match oneclient_core::import_migration_game_dir(source, &folder_name, target).await { + match oneclient_core::import_migration_game_dir(source, &folder_name, target) + .await + { Ok(()) => { notifier.send_info( "Import complete", &format!("Copied your files from {}.", source.display_name()), ); - notifier.invalidate_clusters(); + notifier.invalidate_clusters(); } Err(err) => { notifier.send_error("Import failed", &err.to_string()); @@ -478,6 +484,12 @@ impl CoreBridgeRuntime { BridgeCommand::CloseNotificationCenter => { *notification_center_open = false; } + BridgeCommand::ToggleAccountSwitcher => { + snapshots.account_switcher_open = !snapshots.account_switcher_open; + } + BridgeCommand::CloseAccountSwitcher => { + snapshots.account_switcher_open = false; + } BridgeCommand::ClearNotificationInbox => { inbox.clear(); notification_engine.clear_inbox(); @@ -587,7 +599,8 @@ impl CoreBridgeRuntime { version_id, } => { let state = LauncherState::get()?; - match install_package(&state, provider, &project_id, &version_id, cluster_id).await { + match install_package(&state, provider, &project_id, &version_id, cluster_id).await + { Ok(()) => { state .services @@ -702,7 +715,9 @@ async fn install_package( cluster_id: i64, ) -> anyhow::Result<()> { let provider_impl = state.services.packages.get(provider)?; - let project = provider_impl.get_project(project_id, &state.services).await?; + let project = provider_impl + .get_project(project_id, &state.services) + .await?; let version = provider_impl .get_version(project_id, version_id, &state.services) .await?; @@ -732,7 +747,10 @@ fn apply_game_stage(snapshots: &mut BridgeSnapshot, cluster_id: i64, stage: Laun } fn apply_game_failed(snapshots: &mut BridgeSnapshot, cluster_id: i64, message: String) { - snapshots.game.stages.insert(cluster_id, LaunchStage::Exited); + snapshots + .game + .stages + .insert(cluster_id, LaunchStage::Exited); snapshots.game.error = Some(message); } diff --git a/packages/oneclient_app/src/bridge/snapshot.rs b/packages/oneclient_app/src/bridge/snapshot.rs index 38716959..24054666 100644 --- a/packages/oneclient_app/src/bridge/snapshot.rs +++ b/packages/oneclient_app/src/bridge/snapshot.rs @@ -98,4 +98,5 @@ pub struct BridgeSnapshot { pub java: JavaSnapshot, pub notifications: crate::notifications::NotificationSnapshot, pub game: GameSnapshot, + pub account_switcher_open: bool, } diff --git a/packages/oneclient_app/src/components/account_switcher.rs b/packages/oneclient_app/src/components/account_switcher.rs new file mode 100644 index 00000000..5725d8af --- /dev/null +++ b/packages/oneclient_app/src/components/account_switcher.rs @@ -0,0 +1,257 @@ +use freya::{ + animation::{AnimNum, Ease, OnCreation, use_animation}, + prelude::*, + router::RouterContext, +}; +use oneclient_core::auth::MinecraftAccount; + +use crate::{ + Route, + components::{Avatar, Button, Icon, IconType, OverlayPopup}, + hooks::{ + SetDefaultAccountKeys, try_accounts, try_default_account, use_account_switcher_open, + use_accounts, use_current_account, use_dispatch, use_set_default_account, + }, + theme::colors, +}; + +#[derive(PartialEq)] +pub struct AccountSwitcher; + +impl Component for AccountSwitcher { + fn render(&self) -> impl IntoElement { + let open = use_account_switcher_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_account_switcher()) + .child(AccountPanel) + .into_element() + } +} + +#[derive(PartialEq)] +struct AccountPanel; + +impl Component for AccountPanel { + fn render(&self) -> impl IntoElement { + let accounts_query = use_accounts(); + let current_query = use_current_account(); + + let default_account = try_default_account(¤t_query); + let default_id = default_account.as_ref().map(|account| account.id); + let accounts = try_accounts(&accounts_query).unwrap_or_default(); + + let intro = use_animation(|conf| { + conf.on_creation(OnCreation::Run); + AnimNum::new(0., 1.).time(200).ease(Ease::Out) + }); + let progress = intro.read().value(); + + // active account first, then the rest + let mut ordered: Vec = Vec::new(); + if let Some(account) = default_account.clone() { + ordered.push(account); + } + for account in accounts { + if Some(account.id) != default_id { + ordered.push(account); + } + } + + let mut rows = rect().vertical().width(Size::fill()).spacing(4.); + if ordered.is_empty() { + rows = rows.child( + label() + .text("No accounts yet") + .font_size(13.) + .color(colors::fg_secondary()), + ); + } else { + for account in ordered { + let id = account.id; + rows = rows.child( + AccountRow::new(account, Some(id) == default_id) + .key(id) + .into_element(), + ); + } + } + + rect() + .vertical() + .width(Size::px(300.)) + .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("Accounts") + .font_size(18.) + .font_weight(FontWeight::MEDIUM) + .a11y_role(AccessibilityRole::TitleBar), + ) + .child(divider()) + .child(rows) + .child(divider()) + .child(Footer) + } +} + +struct AccountRow { + account: MinecraftAccount, + active: bool, + key: DiffKey, +} + +impl PartialEq for AccountRow { + fn eq(&self, other: &Self) -> bool { + self.account.id == other.account.id + && self.account.username == other.account.username + && self.active == other.active + } +} + +impl KeyExt for AccountRow { + fn write_key(&mut self) -> &mut DiffKey { + &mut self.key + } +} + +impl AccountRow { + fn new(account: MinecraftAccount, active: bool) -> Self { + Self { + account, + active, + key: DiffKey::None, + } + } +} + +impl Component for AccountRow { + fn render(&self) -> impl IntoElement { + let id = self.account.id; + let username = self.account.username.clone(); + let active = self.active; + + let dispatch = use_dispatch(); + let set_default = use_set_default_account(); + let mut hovered = use_state(|| false); + + let switch = move |_| { + if !active { + set_default.mutate(SetDefaultAccountKeys { id: Some(id) }); + } + dispatch.close_account_switcher(); + }; + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(10.) + .padding(Gaps::new_all(8.)) + .corner_radius(CornerRadius::new_all(8.)) + .maybe(*hovered.read() && !active, |el| { + el.background(colors::ghost_overlay_hover()) + }) + .on_pointer_enter(move |_| { + hovered.set(true); + if !active { + Cursor::set(CursorIcon::Pointer); + } + }) + .on_pointer_leave(move |_| { + hovered.set(false); + Cursor::set(CursorIcon::default()); + }) + .on_press(switch) + .child( + Avatar::new(id.to_string()) + .width(Size::px(32.)) + .height(Size::px(32.)), + ) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .child( + label() + .text(username) + .font_size(14.) + .font_weight(FontWeight::MEDIUM) + .max_lines(1) + .color(colors::fg_primary()), + ) + .maybe(active, |el| { + el.child( + label() + .text("Active") + .font_size(11.) + .color(colors::fg_secondary()), + ) + }), + ) + .maybe(active, |el| { + el.child( + Icon::new(IconType::CheckCircle) + .size(18.) + .color(colors::success()), + ) + }) + } +} + +fn divider() -> impl IntoElement { + rect() + .width(Size::fill()) + .height(Size::px(1.)) + .background(colors::component_border()) +} + +#[derive(PartialEq)] +struct Footer; + +impl Component for Footer { + fn render(&self) -> impl IntoElement { + let dispatch = use_dispatch(); + let open_accounts = move |_| { + dispatch.close_account_switcher(); + let _ = RouterContext::get().push(Route::Accounts {}); + }; + + Button::new() + .ghost() + .small() + .on_press(open_accounts) + .child( + Icon::new(IconType::Users01) + .size(16.) + .color(colors::fg_primary()), + ) + .text("Manage accounts") + } +} diff --git a/packages/oneclient_app/src/components/mod.rs b/packages/oneclient_app/src/components/mod.rs index 42c9d806..40b3a7e2 100644 --- a/packages/oneclient_app/src/components/mod.rs +++ b/packages/oneclient_app/src/components/mod.rs @@ -1,3 +1,4 @@ +mod account_switcher; mod active_cluster_panel; mod avatar; mod button; @@ -28,6 +29,7 @@ mod toasts; mod toggle; mod update_prompt; +pub use account_switcher::AccountSwitcher; pub use active_cluster_panel::ActiveClusterPanel; pub use avatar::Avatar; pub use button::{Button, ButtonSize, ButtonVariant, link_button, open_folder_button}; diff --git a/packages/oneclient_app/src/components/navbar/app_navbar.rs b/packages/oneclient_app/src/components/navbar/app_navbar.rs index 7b3ab333..9f4caf5e 100644 --- a/packages/oneclient_app/src/components/navbar/app_navbar.rs +++ b/packages/oneclient_app/src/components/navbar/app_navbar.rs @@ -170,7 +170,7 @@ struct NavbarRight; impl Component for NavbarRight { fn render(&self) -> impl IntoElement { let current_account = use_current_account(); - let dispatch: crate::BridgeDispatch = use_dispatch(); + let dispatch = use_dispatch(); let unread = use_notifications_snapshot().unread_count(); let account_uuid = try_default_account(¤t_account) @@ -178,8 +178,14 @@ impl Component for NavbarRight { .unwrap_or_else(|| uuid::Uuid::nil().to_string()); // open notification center + let notif_dispatch = dispatch.clone(); let open_notifications = move |_| { - dispatch.toggle_notification_center(); + notif_dispatch.toggle_notification_center(); + }; + + // open account switcher + let open_account_switcher = move |_| { + dispatch.toggle_account_switcher(); }; // open settings @@ -204,11 +210,14 @@ impl Component for NavbarRight { .on_press(open_settings), ) .child( - super::navbar_button().padding(0.0).child( - Avatar::new(account_uuid) - .width(Size::px(24.)) - .height(Size::px(24.)), - ), + super::navbar_button() + .padding(0.0) + .on_press(open_account_switcher) + .child( + Avatar::new(account_uuid) + .width(Size::px(24.)) + .height(Size::px(24.)), + ), ) .child(super::window_controls()) } diff --git a/packages/oneclient_app/src/hooks/dispatch.rs b/packages/oneclient_app/src/hooks/dispatch.rs index 49a35d72..4a3fc3f3 100644 --- a/packages/oneclient_app/src/hooks/dispatch.rs +++ b/packages/oneclient_app/src/hooks/dispatch.rs @@ -152,6 +152,14 @@ impl BridgeDispatch { self.send(BridgeCommand::CloseNotificationCenter); } + pub fn toggle_account_switcher(&self) { + self.send(BridgeCommand::ToggleAccountSwitcher); + } + + pub fn close_account_switcher(&self) { + self.send(BridgeCommand::CloseAccountSwitcher); + } + pub fn clear_notification_inbox(&self) { self.send(BridgeCommand::ClearNotificationInbox); } diff --git a/packages/oneclient_app/src/hooks/mod.rs b/packages/oneclient_app/src/hooks/mod.rs index 46593557..c775b6b2 100644 --- a/packages/oneclient_app/src/hooks/mod.rs +++ b/packages/oneclient_app/src/hooks/mod.rs @@ -88,6 +88,10 @@ pub fn use_notifications_snapshot() -> NotificationSnapshot { use_snapshots().notifications } +pub fn use_account_switcher_open() -> bool { + use_snapshots().account_switcher_open +} + pub fn use_game_snapshot() -> GameSnapshot { use_snapshots().game } diff --git a/packages/oneclient_app/src/layout/root_layout.rs b/packages/oneclient_app/src/layout/root_layout.rs index 96a44da0..664aca99 100644 --- a/packages/oneclient_app/src/layout/root_layout.rs +++ b/packages/oneclient_app/src/layout/root_layout.rs @@ -1,7 +1,9 @@ use freya::prelude::*; use freya::router::*; -use crate::components::{NotificationCenter, StatusBar, Toasts, UpdatePromptOverlay}; +use crate::components::{ + AccountSwitcher, NotificationCenter, StatusBar, Toasts, UpdatePromptOverlay, +}; use crate::routes::Route; use crate::theme; use crate::theme::colors; @@ -26,6 +28,7 @@ impl Component for RootLayout { .child(Outlet::::new()), ) .child(NotificationCenter) + .child(AccountSwitcher) .child(Toasts) .child(UpdatePromptOverlay) .child(StatusBar)