//! The "You" hub — Profile · Stats · Achievements · Replays folded //! into one tabbed modal (Phase E of `docs/ui-redesign-2026-07.md`). //! //! The four screens were previously standalone modals reached one at a //! time through the HUD popover. The hub owns the modal shell (header, //! tab chips via the shared [`spawn_tab_chip`] widget, a single Done //! button); each tab's content is a body builder that lives in its //! original plugin (`spawn_profile_body`, `spawn_stats_body`, //! `spawn_achievements_body`, `spawn_replays_body`) so every marker //! component and per-row update system keeps working unchanged. //! //! Legacy screen markers (`ProfileScreen`, `StatsScreen`, //! `AchievementsScreen`) are inserted on the hub scrim while their tab //! is active, so existing queries and tests keep their meaning. //! //! Open paths: `ToggleProfileRequestEvent` / `ToggleStatsRequestEvent` //! / `ToggleAchievementsRequestEvent` (HUD popover) and the P / S / A //! accelerators — each opens the hub pre-selected to its tab, toggles //! the hub closed when its tab is already showing, or switches tabs //! when a different tab is showing. Esc, Done, and scrim-click close. use bevy::ecs::system::SystemParam; use bevy::prelude::*; use solitaire_data::StatsSnapshot; use crate::achievement_plugin::{AchievementsResource, AchievementsScreen}; use crate::events::{ ToggleAchievementsRequestEvent, ToggleProfileRequestEvent, ToggleStatsRequestEvent, }; use crate::font_plugin::FontResource; use crate::profile_plugin::{AvatarResource, ProfileScreen}; use crate::progress_plugin::ProgressResource; use crate::resources::SyncStatusResource; use crate::settings_plugin::SettingsResource; use crate::stats_plugin::{ReplayHistoryResource, SelectedReplayIndex, StatsResource, StatsScreen}; use crate::time_attack_plugin::TimeAttackResource; use crate::ui_focus::FocusRow; use crate::ui_modal::{ ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, spawn_tab_chip, }; use crate::ui_theme::{VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL}; /// Which tab of the You hub is showing. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum YouTab { #[default] Profile, Stats, Achievements, Replays, } impl YouTab { /// Every tab, in display order. pub const ALL: [Self; 4] = [ Self::Profile, Self::Stats, Self::Achievements, Self::Replays, ]; /// Chip label. pub fn label(self) -> &'static str { match self { Self::Profile => "Profile", Self::Stats => "Stats", Self::Achievements => "Awards", Self::Replays => "Replays", } } } /// The active hub tab. Session-only; reopening returns to the last tab /// unless an open request names a different one. #[derive(Resource, Debug, Default)] pub struct ActiveYouTab(pub YouTab); /// Marker on the hub modal's scrim root. #[derive(Component)] pub struct YouHubScreen; /// Per-chip tab selector button. #[derive(Component, Debug)] struct YouHubTabButton(YouTab); /// Marker on the hub's Done button. #[derive(Component)] struct YouHubCloseButton; /// Read-only bundle of everything the tab bodies render from. Split /// out as a [`SystemParam`] because the open + rebuild systems both /// need the full set and Bevy caps systems at 16 parameters. #[derive(SystemParam)] struct YouHubContext<'w> { settings: Option>, sync_status: Option>, progress: Option>, achievements: Option>, stats: Option>, avatar: Option>, time_attack: Option>, replay_history: Option>, selected_replay: Option>, font_res: Option>, } /// Bevy plugin owning the You hub lifecycle. Requires the profile, /// stats, and achievement plugins for live data; degrades to empty tab /// bodies without them (headless tests). pub struct YouHubPlugin; impl Plugin for YouHubPlugin { fn build(&self, app: &mut App) { app.init_resource::() .add_message::() .add_message::() .add_message::() .add_systems( Update, // Chained: a toggle/chip press must be observed before // the rebuild, and the rebuild before close — total // order prevents double-spawns within one frame. ( open_or_toggle_you_hub, handle_tab_buttons, rebuild_on_tab_change, handle_close_button, ) .chain(), ); } } /// Maps this frame's toggle events + accelerator keys to a requested /// tab, mirroring the semantics the three standalone screens had. fn requested_tab( profile_events: &mut MessageReader, stats_events: &mut MessageReader, achievements_events: &mut MessageReader, keys: &ButtonInput, ) -> Option { let profile = profile_events.read().count() > 0 || keys.just_pressed(KeyCode::KeyP); let stats = stats_events.read().count() > 0 || keys.just_pressed(KeyCode::KeyS); let achievements = achievements_events.read().count() > 0 || keys.just_pressed(KeyCode::KeyA); if profile { Some(YouTab::Profile) } else if stats { Some(YouTab::Stats) } else if achievements { Some(YouTab::Achievements) } else { None } } /// Opens the hub on the requested tab, switches tabs when it's already /// open on a different one, toggles it closed on a same-tab request or /// Esc. #[allow(clippy::too_many_arguments)] fn open_or_toggle_you_hub( mut commands: Commands, keys: Res>, mut profile_events: MessageReader, mut stats_events: MessageReader, mut achievements_events: MessageReader, screens: Query>, other_modal_scrims: Query<(), (With, Without)>, mut active: ResMut, ctx: YouHubContext, ) { let requested = requested_tab( &mut profile_events, &mut stats_events, &mut achievements_events, &keys, ); let open = !screens.is_empty(); if open { // Esc closes the topmost modal — the hub, when it's showing. if keys.just_pressed(KeyCode::Escape) { for entity in &screens { commands.entity(entity).despawn(); } return; } match requested { Some(tab) if tab == active.0 => { // Same-tab request toggles closed (P opens Profile, // P again closes — parity with the old screens). for entity in &screens { commands.entity(entity).despawn(); } } Some(tab) => { // Different tab: switch in place. The rebuild system // (next in the chain) observes the change. active.0 = tab; } None => {} } return; } let Some(tab) = requested else { return }; if !other_modal_scrims.is_empty() { return; // Another modal is already visible (§14.2). } if active.0 != tab { // Written before the spawn; the rebuild system also runs this // frame but sees no live hub (the spawn below is deferred), so // no double-spawn. active.0 = tab; } spawn_you_hub(&mut commands, tab, &ctx); } /// Switches the active tab when a chip is pressed. fn handle_tab_buttons( interactions: Query<(&Interaction, &YouHubTabButton), Changed>, mut active: ResMut, ) { for (interaction, chip) in &interactions { if *interaction != Interaction::Pressed { continue; } if active.0 != chip.0 { active.0 = chip.0; } } } /// Rebuilds the open hub when [`ActiveYouTab`] changes. fn rebuild_on_tab_change( active: Res, screens: Query>, mut commands: Commands, ctx: YouHubContext, ) { if !active.is_changed() || active.is_added() { return; } if screens.is_empty() { return; } for entity in &screens { commands.entity(entity).despawn(); } spawn_you_hub(&mut commands, active.0, &ctx); } /// Despawns the hub when Done is pressed. fn handle_close_button( interactions: Query<&Interaction, (Changed, With)>, screens: Query>, mut commands: Commands, ) { for interaction in &interactions { if *interaction != Interaction::Pressed { continue; } for entity in &screens { commands.entity(entity).despawn(); } } } /// Spawns the hub modal showing `tab`, and stamps the scrim with the /// tab's legacy screen marker so pre-hub queries keep working. fn spawn_you_hub(commands: &mut Commands, tab: YouTab, ctx: &YouHubContext) { let font_res = ctx.font_res.as_deref(); let scrim = spawn_modal(commands, YouHubScreen, Z_MODAL_PANEL, |card| { spawn_modal_header(card, "You", font_res); // Tab chips — shared widget with the Settings panel. card.spawn(( FocusRow, Node { flex_direction: FlexDirection::Row, flex_wrap: FlexWrap::Wrap, column_gap: VAL_SPACE_2, row_gap: VAL_SPACE_2, margin: UiRect::bottom(VAL_SPACE_3), ..default() }, )) .with_children(|row| { for chip_tab in YouTab::ALL { spawn_tab_chip( row, YouHubTabButton(chip_tab), chip_tab.label(), chip_tab == tab, font_res, ); } }); match tab { YouTab::Profile => crate::profile_plugin::spawn_profile_body( card, ctx.settings.as_deref(), ctx.sync_status.as_deref(), ctx.progress.as_deref(), ctx.achievements.as_deref(), ctx.stats.as_deref(), font_res, ctx.avatar.as_deref(), ), YouTab::Stats => { let default_stats = StatsSnapshot::default(); crate::stats_plugin::spawn_stats_body( card, ctx.stats.as_deref().map_or(&default_stats, |s| &s.0), ctx.progress.as_deref().map(|p| &p.0), ctx.time_attack.as_deref(), font_res, ); } YouTab::Achievements => crate::achievement_plugin::spawn_achievements_body( card, ctx.achievements .as_deref() .map_or(&[][..], |a| a.0.as_slice()), font_res, ), YouTab::Replays => crate::stats_plugin::spawn_replays_body( card, ctx.replay_history .as_deref() .map_or(&[][..], |h| h.0.replays.as_slice()), ctx.selected_replay.as_deref().map_or(0, |s| s.0), font_res, ), } spawn_modal_actions(card, |actions| { spawn_modal_button( actions, YouHubCloseButton, "Done", None, ButtonVariant::Primary, font_res, ); }); }); let mut scrim_commands = commands.entity(scrim); scrim_commands.insert(ScrimDismissible); // Legacy markers: pre-hub code and tests query these. match tab { YouTab::Profile => { scrim_commands.insert(ProfileScreen); } YouTab::Stats | YouTab::Replays => { scrim_commands.insert(StatsScreen); } YouTab::Achievements => { scrim_commands.insert(AchievementsScreen); } } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; fn headless_app() -> App { let mut app = App::new(); app.add_plugins(MinimalPlugins).add_plugins(YouHubPlugin); app.init_resource::>(); app.update(); app } fn hub_count(app: &mut App) -> usize { app.world_mut() .query::<&YouHubScreen>() .iter(app.world()) .count() } /// Each Toggle*RequestEvent opens the hub pre-selected to its tab, /// stamped with the tab's legacy screen marker. #[test] fn toggle_events_open_hub_on_their_tab() { use bevy::ecs::message::Messages; let mut app = headless_app(); app.world_mut() .resource_mut::>() .write(ToggleStatsRequestEvent); app.update(); app.update(); assert_eq!(hub_count(&mut app), 1, "stats request must open the hub"); assert_eq!(app.world().resource::().0, YouTab::Stats); assert_eq!( app.world_mut() .query::<&StatsScreen>() .iter(app.world()) .count(), 1, "legacy StatsScreen marker must ride the hub scrim" ); // A different tab's request switches in place — still one hub. app.world_mut() .resource_mut::>() .write(ToggleAchievementsRequestEvent); app.update(); app.update(); assert_eq!(hub_count(&mut app), 1, "tab switch must not stack hubs"); assert_eq!( app.world().resource::().0, YouTab::Achievements ); assert_eq!( app.world_mut() .query::<&AchievementsScreen>() .iter(app.world()) .count(), 1 ); // Same-tab request toggles the hub closed. app.world_mut() .resource_mut::>() .write(ToggleAchievementsRequestEvent); app.update(); app.update(); assert_eq!(hub_count(&mut app), 0, "same-tab request must close"); } /// Chip-driven tab switches rebuild the single hub with the new /// tab's body (Profile scrollable swaps for the Stats one). #[test] fn tab_switch_swaps_bodies_without_stacking() { use crate::profile_plugin::ProfileScrollable; use crate::stats_plugin::StatsScrollable; use bevy::ecs::message::Messages; let mut app = headless_app(); app.world_mut() .resource_mut::>() .write(ToggleProfileRequestEvent); app.update(); app.update(); assert_eq!( app.world_mut() .query::<&ProfileScrollable>() .iter(app.world()) .count(), 1, "profile body must spawn on the Profile tab" ); app.world_mut().resource_mut::().0 = YouTab::Stats; app.update(); app.update(); assert_eq!(hub_count(&mut app), 1, "rebuild must not stack scrims"); assert_eq!( app.world_mut() .query::<&ProfileScrollable>() .iter(app.world()) .count(), 0, "profile body must despawn when leaving the tab" ); assert_eq!( app.world_mut() .query::<&StatsScrollable>() .iter(app.world()) .count(), 1, "stats body must spawn on the Stats tab" ); } }