feat(engine): You hub — Profile/Stats/Achievements/Replays in one tabbed modal
Test / test (pull_request) Failing after 8m41s

Phase E of docs/ui-redesign-2026-07.md. New you_hub_plugin owns the
modal shell (header, shared tab chips, single Done); each tab's content
is a body builder extracted from its original plugin with every marker
component unchanged, so per-row update/scroll/selector systems keep
working. The replay selector gets its own Replays tab (Watch/Copy
buttons move into the tab body).

- Toggle*RequestEvents + P/S/A accelerators open the hub on the right
  tab, switch tabs in place, or toggle closed on a same-tab request;
  Esc/Done/scrim-click close
- Legacy ProfileScreen/StatsScreen/AchievementsScreen markers ride the
  hub scrim for the active tab — external queries and tests keep their
  meaning
- Standalone toggle/close systems and per-screen Done buttons removed
  (ProfileCloseButton, StatsCloseButton, AchievementsCloseButton)
- Tests: 2 new hub lifecycle tests; profile/stats/achievements modal
  tests adapted (fixtures add YouHubPlugin; selector tests target the
  Replays tab). Engine suite 916 green, clippy -D warnings, fmt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-07-07 17:10:56 -07:00
parent 0c69d6859d
commit 9f038250d9
6 changed files with 618 additions and 277 deletions
+486
View File
@@ -0,0 +1,486 @@
//! 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<Res<'w, SettingsResource>>,
sync_status: Option<Res<'w, SyncStatusResource>>,
progress: Option<Res<'w, ProgressResource>>,
achievements: Option<Res<'w, AchievementsResource>>,
stats: Option<Res<'w, StatsResource>>,
avatar: Option<Res<'w, AvatarResource>>,
time_attack: Option<Res<'w, TimeAttackResource>>,
replay_history: Option<Res<'w, ReplayHistoryResource>>,
selected_replay: Option<Res<'w, SelectedReplayIndex>>,
font_res: Option<Res<'w, FontResource>>,
}
/// 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::<ActiveYouTab>()
.add_message::<ToggleProfileRequestEvent>()
.add_message::<ToggleStatsRequestEvent>()
.add_message::<ToggleAchievementsRequestEvent>()
.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<ToggleProfileRequestEvent>,
stats_events: &mut MessageReader<ToggleStatsRequestEvent>,
achievements_events: &mut MessageReader<ToggleAchievementsRequestEvent>,
keys: &ButtonInput<KeyCode>,
) -> Option<YouTab> {
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<ButtonInput<KeyCode>>,
mut profile_events: MessageReader<ToggleProfileRequestEvent>,
mut stats_events: MessageReader<ToggleStatsRequestEvent>,
mut achievements_events: MessageReader<ToggleAchievementsRequestEvent>,
screens: Query<Entity, With<YouHubScreen>>,
other_modal_scrims: Query<(), (With<ModalScrim>, Without<YouHubScreen>)>,
mut active: ResMut<ActiveYouTab>,
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<Interaction>>,
mut active: ResMut<ActiveYouTab>,
) {
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<ActiveYouTab>,
screens: Query<Entity, With<YouHubScreen>>,
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<Interaction>, With<YouHubCloseButton>)>,
screens: Query<Entity, With<YouHubScreen>>,
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::<ButtonInput<KeyCode>>();
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::<Messages<ToggleStatsRequestEvent>>()
.write(ToggleStatsRequestEvent);
app.update();
app.update();
assert_eq!(hub_count(&mut app), 1, "stats request must open the hub");
assert_eq!(app.world().resource::<ActiveYouTab>().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::<Messages<ToggleAchievementsRequestEvent>>()
.write(ToggleAchievementsRequestEvent);
app.update();
app.update();
assert_eq!(hub_count(&mut app), 1, "tab switch must not stack hubs");
assert_eq!(
app.world().resource::<ActiveYouTab>().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::<Messages<ToggleAchievementsRequestEvent>>()
.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::<Messages<ToggleProfileRequestEvent>>()
.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::<ActiveYouTab>().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"
);
}
}