feat(engine): You hub — Profile/Stats/Achievements/Replays in one tabbed modal (Phase E) #161
@@ -30,13 +30,9 @@ use crate::replay_playback::ReplayPlaybackState;
|
||||
use crate::resources::GameStateResource;
|
||||
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
||||
use crate::stats_plugin::{StatsResource, StatsUpdate};
|
||||
use crate::ui_modal::{
|
||||
ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
|
||||
spawn_modal_button, spawn_modal_header,
|
||||
};
|
||||
use crate::ui_theme::{
|
||||
ACCENT_PRIMARY, BORDER_SUBTLE, STATE_SUCCESS, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY,
|
||||
TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_1, Z_MODAL_PANEL,
|
||||
TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_1,
|
||||
};
|
||||
use crate::ui_tooltip::Tooltip;
|
||||
|
||||
@@ -137,8 +133,8 @@ impl Plugin for AchievementPlugin {
|
||||
.after(GameMutation)
|
||||
.after(StatsUpdate),
|
||||
)
|
||||
.add_systems(Update, toggle_achievements_screen)
|
||||
.add_systems(Update, handle_achievements_close_button)
|
||||
// Open/close/tab handling moved to `you_hub_plugin`
|
||||
// (Phase E) — this plugin now owns body content + scroll.
|
||||
.add_systems(Update, scroll_achievements_panel)
|
||||
.add_systems(
|
||||
Update,
|
||||
@@ -385,47 +381,6 @@ pub fn display_name_for(id: &str) -> String {
|
||||
achievement_by_id(id).map_or_else(|| id.to_string(), |d| d.name.to_string())
|
||||
}
|
||||
|
||||
/// Marker on the "Done" button inside the Achievements modal.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct AchievementsCloseButton;
|
||||
|
||||
/// Toggle the achievements overlay — `A` keyboard accelerator or
|
||||
/// `ToggleAchievementsRequestEvent` from the HUD Menu popover.
|
||||
fn toggle_achievements_screen(
|
||||
mut commands: Commands,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
mut requests: MessageReader<ToggleAchievementsRequestEvent>,
|
||||
achievements: Res<AchievementsResource>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
screens: Query<Entity, With<AchievementsScreen>>,
|
||||
other_modal_scrims: Query<(), (With<ModalScrim>, Without<AchievementsScreen>)>,
|
||||
) {
|
||||
let button_clicked = requests.read().count() > 0;
|
||||
if !keys.just_pressed(KeyCode::KeyA) && !button_clicked {
|
||||
return;
|
||||
}
|
||||
if let Ok(entity) = screens.single() {
|
||||
commands.entity(entity).despawn();
|
||||
} else if other_modal_scrims.is_empty() {
|
||||
spawn_achievements_screen(&mut commands, &achievements.0, font_res.as_deref());
|
||||
}
|
||||
}
|
||||
|
||||
/// Click handler for the modal's "Done" button — despawns the overlay
|
||||
/// the same way the `A` accelerator does.
|
||||
fn handle_achievements_close_button(
|
||||
mut commands: Commands,
|
||||
close_buttons: Query<&Interaction, (With<AchievementsCloseButton>, Changed<Interaction>)>,
|
||||
screens: Query<Entity, With<AchievementsScreen>>,
|
||||
) {
|
||||
if !close_buttons.iter().any(|i| *i == Interaction::Pressed) {
|
||||
return;
|
||||
}
|
||||
for entity in &screens {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
}
|
||||
|
||||
/// Routes mouse-wheel events into the Achievements modal's scrollable body
|
||||
/// while the panel is open.
|
||||
///
|
||||
@@ -458,14 +413,18 @@ fn scroll_achievements_panel(
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_achievements_screen(
|
||||
commands: &mut Commands,
|
||||
/// Builds the Achievements tab body inside the You hub's card. The
|
||||
/// unlock-count line that used to live in the standalone modal's
|
||||
/// header renders as the first body line instead (the hub owns the
|
||||
/// header). All markers (`AchievementRow`, `AchievementsScrollable`)
|
||||
/// are unchanged.
|
||||
pub(crate) fn spawn_achievements_body(
|
||||
card: &mut ChildSpawnerCommands,
|
||||
records: &[AchievementRecord],
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
let unlocked: Vec<_> = records.iter().filter(|r| r.unlocked).collect();
|
||||
let total = ALL_ACHIEVEMENTS.len();
|
||||
let header = format!("Achievements ({}/{})", unlocked.len(), total);
|
||||
|
||||
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
|
||||
let font_name = TextFont {
|
||||
@@ -486,8 +445,13 @@ fn spawn_achievements_screen(
|
||||
|
||||
let any_unlocked = records.iter().any(|r| r.unlocked);
|
||||
|
||||
let scrim = spawn_modal(commands, AchievementsScreen, Z_MODAL_PANEL, |card| {
|
||||
spawn_modal_header(card, header, font_res);
|
||||
{
|
||||
// Unlock progress — formerly the standalone modal's header.
|
||||
card.spawn((
|
||||
Text::new(format!("Unlocked {} / {}", unlocked.len(), total)),
|
||||
font_name.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
|
||||
// First-time hint — shown until the player has unlocked anything.
|
||||
// The list itself describes individual rewards, but a top-level
|
||||
@@ -594,21 +558,7 @@ fn spawn_achievements_screen(
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
spawn_modal_actions(card, |actions| {
|
||||
spawn_modal_button(
|
||||
actions,
|
||||
AchievementsCloseButton,
|
||||
"Done",
|
||||
Some("A"),
|
||||
ButtonVariant::Primary,
|
||||
font_res,
|
||||
);
|
||||
});
|
||||
});
|
||||
// Achievements is a read-only list — clicking the scrim outside
|
||||
// the card dismisses alongside the existing A / Done paths.
|
||||
commands.entity(scrim).insert(ScrimDismissible);
|
||||
}
|
||||
}
|
||||
|
||||
fn format_reward(reward: Reward) -> String {
|
||||
@@ -668,7 +618,8 @@ mod tests {
|
||||
.add_plugins(TablePlugin)
|
||||
.add_plugins(StatsPlugin::headless())
|
||||
.add_plugins(crate::progress_plugin::ProgressPlugin::headless())
|
||||
.add_plugins(AchievementPlugin::headless());
|
||||
.add_plugins(AchievementPlugin::headless())
|
||||
.add_plugins(crate::you_hub_plugin::YouHubPlugin);
|
||||
// StatsPlugin's UI toggle system reads ButtonInput<KeyCode>; under
|
||||
// MinimalPlugins it isn't auto-registered.
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
|
||||
@@ -109,6 +109,7 @@ impl Plugin for CoreGamePlugin {
|
||||
.add_plugins(HelpPlugin)
|
||||
.add_plugins(HomePlugin::default())
|
||||
.add_plugins(ProfilePlugin)
|
||||
.add_plugins(crate::you_hub_plugin::YouHubPlugin)
|
||||
.add_plugins(PausePlugin)
|
||||
.add_plugins(SettingsPlugin::default())
|
||||
.add_plugins(OnboardingPlugin)
|
||||
|
||||
@@ -64,6 +64,7 @@ pub mod ui_theme;
|
||||
pub mod ui_tooltip;
|
||||
pub mod weekly_goals_plugin;
|
||||
pub mod win_summary_plugin;
|
||||
pub mod you_hub_plugin;
|
||||
|
||||
pub use achievement_plugin::{AchievementPlugin, AchievementsResource, AchievementsScreen};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -195,3 +196,4 @@ pub use weekly_goals_plugin::{WeeklyGoalCompletedEvent, WeeklyGoalsPlugin};
|
||||
pub use win_summary_plugin::{
|
||||
ScreenShakeResource, SessionAchievements, WinSummaryPending, WinSummaryPlugin, format_win_time,
|
||||
};
|
||||
pub use you_hub_plugin::{ActiveYouTab, YouHubPlugin, YouHubScreen, YouTab};
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
//! summary in a single scrollable panel. Spawned on the first `P` keypress and
|
||||
//! despawned on the second.
|
||||
|
||||
use bevy::input::ButtonInput;
|
||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||
use bevy::prelude::*;
|
||||
use chrono::{Duration, Local, NaiveDate};
|
||||
@@ -13,23 +12,19 @@ use solitaire_data::SyncBackend;
|
||||
|
||||
use crate::achievement_plugin::AchievementsResource;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use crate::avatar_plugin::AvatarResource;
|
||||
pub(crate) use crate::avatar_plugin::AvatarResource;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[derive(bevy::prelude::Resource)]
|
||||
struct AvatarResource(Option<bevy::prelude::Handle<bevy::prelude::Image>>);
|
||||
pub(crate) struct AvatarResource(Option<bevy::prelude::Handle<bevy::prelude::Image>>);
|
||||
use crate::events::ToggleProfileRequestEvent;
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
use crate::resources::{SyncStatus, SyncStatusResource};
|
||||
use crate::settings_plugin::SettingsResource;
|
||||
use crate::stats_plugin::{StatsResource, format_fastest_win, format_win_rate};
|
||||
use crate::ui_modal::{
|
||||
ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
|
||||
spawn_modal_button, spawn_modal_header,
|
||||
};
|
||||
use crate::ui_theme::{
|
||||
ACCENT_PRIMARY, BG_ELEVATED, BORDER_STRONG, SPACE_1, STATE_INFO, STATE_SUCCESS, TEXT_PRIMARY,
|
||||
TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2, Z_MODAL_PANEL,
|
||||
TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2,
|
||||
};
|
||||
|
||||
/// Number of days surfaced in the daily-challenge calendar row.
|
||||
@@ -60,13 +55,11 @@ pub struct DailyCalendarDot {
|
||||
pub is_today: bool,
|
||||
}
|
||||
|
||||
/// Registers the `P` key toggle for the profile overlay.
|
||||
/// Registers the Profile body's scroll handling. Opening/closing moved
|
||||
/// to the You hub (`you_hub_plugin`), which calls
|
||||
/// [`spawn_profile_body`] for this tab's content.
|
||||
pub struct ProfilePlugin;
|
||||
|
||||
/// Marker on the "Done" button inside the Profile modal.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ProfileCloseButton;
|
||||
|
||||
/// Marker on the scrollable body Node inside the Profile modal.
|
||||
///
|
||||
/// The Profile panel renders sync info, progression (incl. 14-day
|
||||
@@ -87,14 +80,9 @@ impl Plugin for ProfilePlugin {
|
||||
// profile-scroll system also runs cleanly under
|
||||
// `MinimalPlugins` in tests.
|
||||
.add_message::<MouseWheel>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
toggle_profile_screen,
|
||||
handle_profile_close_button,
|
||||
scroll_profile_panel,
|
||||
),
|
||||
);
|
||||
// Open/close/tab handling moved to `you_hub_plugin`
|
||||
// (Phase E) — this plugin now owns body content + scroll.
|
||||
.add_systems(Update, scroll_profile_panel);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,70 +112,12 @@ fn scroll_profile_panel(
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_profile_close_button(
|
||||
mut commands: Commands,
|
||||
close_buttons: Query<&Interaction, (With<ProfileCloseButton>, Changed<Interaction>)>,
|
||||
screens: Query<Entity, With<ProfileScreen>>,
|
||||
) {
|
||||
if !close_buttons.iter().any(|i| *i == Interaction::Pressed) {
|
||||
return;
|
||||
}
|
||||
for entity in &screens {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the Profile tab body inside the You hub's card. All markers
|
||||
/// (`ProfileScrollable`, `DailyCalendarDot`, …) are unchanged, so the
|
||||
/// scroll and test queries keep working.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn toggle_profile_screen(
|
||||
mut commands: Commands,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
mut requests: MessageReader<ToggleProfileRequestEvent>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
sync_status: Option<Res<SyncStatusResource>>,
|
||||
progress: Option<Res<ProgressResource>>,
|
||||
achievements: Option<Res<AchievementsResource>>,
|
||||
stats: Option<Res<StatsResource>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
avatar: Option<Res<AvatarResource>>,
|
||||
screens: Query<Entity, With<ProfileScreen>>,
|
||||
scrims: Query<(), With<ModalScrim>>,
|
||||
) {
|
||||
let button_clicked = requests.read().count() > 0;
|
||||
let p_pressed = keys.just_pressed(KeyCode::KeyP);
|
||||
let esc_pressed = keys.just_pressed(KeyCode::Escape);
|
||||
let already_open = !screens.is_empty();
|
||||
// P / button toggles open-or-close. Esc only ever closes — when
|
||||
// Profile is layered over Home (clicking the new Home stats chip
|
||||
// opens this on top), Esc must dismiss the *topmost* modal.
|
||||
// Without this branch, Esc fell through to Home's cancel handler
|
||||
// and closed the wrong modal.
|
||||
let want_open = !already_open && (p_pressed || button_clicked);
|
||||
let want_close = already_open && (p_pressed || button_clicked || esc_pressed);
|
||||
if !want_open && !want_close {
|
||||
return;
|
||||
}
|
||||
if want_open && !scrims.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Ok(entity) = screens.single() {
|
||||
commands.entity(entity).despawn();
|
||||
} else {
|
||||
spawn_profile_screen(
|
||||
&mut commands,
|
||||
settings.as_deref(),
|
||||
sync_status.as_deref(),
|
||||
progress.as_deref(),
|
||||
achievements.as_deref(),
|
||||
stats.as_deref(),
|
||||
font_res.as_deref(),
|
||||
avatar.as_deref(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn spawn_profile_screen(
|
||||
commands: &mut Commands,
|
||||
pub(crate) fn spawn_profile_body(
|
||||
card: &mut ChildSpawnerCommands,
|
||||
settings: Option<&SettingsResource>,
|
||||
sync_status: Option<&SyncStatusResource>,
|
||||
progress: Option<&ProgressResource>,
|
||||
@@ -208,9 +138,7 @@ fn spawn_profile_screen(
|
||||
..default()
|
||||
};
|
||||
|
||||
let scrim = spawn_modal(commands, ProfileScreen, Z_MODAL_PANEL, |card| {
|
||||
spawn_modal_header(card, "Profile", font_res);
|
||||
|
||||
{
|
||||
// Scrollable body — the Profile panel renders sync info,
|
||||
// progression (incl. a 14-day calendar), every unlocked
|
||||
// achievement (up to ~18), and a stats summary, which can
|
||||
@@ -472,20 +400,7 @@ fn spawn_profile_screen(
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
spawn_modal_actions(card, |actions| {
|
||||
spawn_modal_button(
|
||||
actions,
|
||||
ProfileCloseButton,
|
||||
"Done",
|
||||
Some("P"),
|
||||
ButtonVariant::Primary,
|
||||
font_res,
|
||||
);
|
||||
});
|
||||
});
|
||||
// Profile is read-only — opt into click-outside-to-dismiss.
|
||||
commands.entity(scrim).insert(ScrimDismissible);
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn a fixed-height vertical spacer node.
|
||||
@@ -627,6 +542,7 @@ mod tests {
|
||||
use crate::settings_plugin::SettingsPlugin;
|
||||
use crate::stats_plugin::StatsPlugin;
|
||||
use crate::table_plugin::TablePlugin;
|
||||
use crate::ui_modal::ModalScrim;
|
||||
|
||||
fn headless_app() -> App {
|
||||
let mut app = App::new();
|
||||
@@ -637,7 +553,8 @@ mod tests {
|
||||
.add_plugins(ProgressPlugin::headless())
|
||||
.add_plugins(AchievementPlugin::headless())
|
||||
.add_plugins(SettingsPlugin::headless())
|
||||
.add_plugins(ProfilePlugin);
|
||||
.add_plugins(ProfilePlugin)
|
||||
.add_plugins(crate::you_hub_plugin::YouHubPlugin);
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.update();
|
||||
app
|
||||
|
||||
@@ -18,6 +18,7 @@ use crate::theme::{ImportError, import_theme, refresh_registry};
|
||||
use crate::ui_focus::FocusRow;
|
||||
use crate::ui_modal::{
|
||||
ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header,
|
||||
spawn_tab_chip,
|
||||
};
|
||||
use crate::ui_theme::{
|
||||
BG_BASE, BG_ELEVATED, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM,
|
||||
@@ -117,41 +118,22 @@ pub(super) fn spawn_settings_panel(
|
||||
});
|
||||
}
|
||||
|
||||
/// One Settings tab chip. The active chip is filled + bright; inactive
|
||||
/// chips are quiet outlines.
|
||||
/// One Settings tab chip — thin wrapper over the shared
|
||||
/// [`spawn_tab_chip`] widget so Settings and the You hub stay visually
|
||||
/// identical.
|
||||
fn tab_chip(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
tab: SettingsTab,
|
||||
active: bool,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
let font = TextFont {
|
||||
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
};
|
||||
parent
|
||||
.spawn((
|
||||
SettingsTabButton(tab),
|
||||
Button,
|
||||
Node {
|
||||
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2),
|
||||
justify_content: JustifyContent::Center,
|
||||
border: UiRect::all(Val::Px(1.0)),
|
||||
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(if active { BG_ELEVATED_HI } else { BG_BASE }),
|
||||
BorderColor::all(if active { STATE_SUCCESS } else { BORDER_SUBTLE }),
|
||||
HighContrastBorder::with_default(if active { STATE_SUCCESS } else { BORDER_SUBTLE }),
|
||||
))
|
||||
.with_children(|b| {
|
||||
b.spawn((
|
||||
Text::new(tab.label()),
|
||||
font,
|
||||
TextColor(if active { TEXT_PRIMARY } else { TEXT_SECONDARY }),
|
||||
));
|
||||
});
|
||||
spawn_tab_chip(
|
||||
parent,
|
||||
SettingsTabButton(tab),
|
||||
tab.label(),
|
||||
active,
|
||||
font_res,
|
||||
);
|
||||
}
|
||||
|
||||
/// Audio tab: the two volume rows.
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use bevy::input::ButtonInput;
|
||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||
use bevy::prelude::*;
|
||||
use solitaire_data::{
|
||||
@@ -25,17 +24,13 @@ use crate::events::{
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::platform::ClipboardBackendResource;
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
use crate::resources::GameStateResource;
|
||||
use crate::time_attack_plugin::TimeAttackResource;
|
||||
use crate::ui_modal::{
|
||||
ButtonVariant, ModalButton, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
|
||||
spawn_modal_button, spawn_modal_header,
|
||||
};
|
||||
use crate::ui_modal::{ButtonVariant, ModalButton, spawn_modal_button};
|
||||
use crate::ui_theme::{
|
||||
ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, STATE_INFO,
|
||||
STATE_WARNING, STREAK_MILESTONES, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG,
|
||||
TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, Z_MODAL_PANEL,
|
||||
TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4,
|
||||
};
|
||||
|
||||
/// Bevy resource wrapping the current stats.
|
||||
@@ -225,8 +220,9 @@ impl Plugin for StatsPlugin {
|
||||
.before(GameMutation)
|
||||
.before(update_stats_on_new_game),
|
||||
)
|
||||
.add_systems(Update, toggle_stats_screen.after(GameMutation))
|
||||
.add_systems(Update, handle_stats_close_button)
|
||||
// Open/close/tab handling moved to `you_hub_plugin`
|
||||
// (Phase E) — this plugin now owns body content, replay
|
||||
// selector behavior, and scroll.
|
||||
.add_systems(Update, refresh_replay_history_on_win.after(GameMutation))
|
||||
.add_systems(Update, handle_watch_replay_button)
|
||||
.add_systems(Update, handle_copy_share_link_button)
|
||||
@@ -632,70 +628,17 @@ fn handle_forfeit(
|
||||
}
|
||||
}
|
||||
|
||||
/// Marker on the "Done" button inside the Stats modal. Click despawns
|
||||
/// the overlay; `S` keyboard shortcut toggles it the same way.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct StatsCloseButton;
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn toggle_stats_screen(
|
||||
mut commands: Commands,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
mut requests: MessageReader<ToggleStatsRequestEvent>,
|
||||
stats: Res<StatsResource>,
|
||||
progress: Option<Res<ProgressResource>>,
|
||||
time_attack: Option<Res<TimeAttackResource>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
latest_replay: Res<ReplayHistoryResource>,
|
||||
selected_index: Res<SelectedReplayIndex>,
|
||||
screens: Query<Entity, With<StatsScreen>>,
|
||||
other_modal_scrims: Query<(), (With<ModalScrim>, Without<StatsScreen>)>,
|
||||
) {
|
||||
let button_clicked = requests.read().count() > 0;
|
||||
if !keys.just_pressed(KeyCode::KeyS) && !button_clicked {
|
||||
return;
|
||||
}
|
||||
if let Ok(entity) = screens.single() {
|
||||
commands.entity(entity).despawn();
|
||||
} else {
|
||||
if !other_modal_scrims.is_empty() {
|
||||
return;
|
||||
}
|
||||
spawn_stats_screen(
|
||||
&mut commands,
|
||||
&stats.0,
|
||||
progress.as_deref().map(|p| &p.0),
|
||||
time_attack.as_deref(),
|
||||
font_res.as_deref(),
|
||||
&latest_replay.0.replays,
|
||||
selected_index.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Click handler for the modal's "Done" button — despawns the overlay
|
||||
/// the same way the `S` accelerator does.
|
||||
fn handle_stats_close_button(
|
||||
mut commands: Commands,
|
||||
close_buttons: Query<&Interaction, (With<StatsCloseButton>, Changed<Interaction>)>,
|
||||
screens: Query<Entity, With<StatsScreen>>,
|
||||
) {
|
||||
if !close_buttons.iter().any(|i| *i == Interaction::Pressed) {
|
||||
return;
|
||||
}
|
||||
for entity in &screens {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_stats_screen(
|
||||
commands: &mut Commands,
|
||||
/// Builds the Stats tab body inside the You hub's card: the 8-cell
|
||||
/// grid plus per-mode bests, progression, weekly goals, unlocks, and
|
||||
/// the optional Time Attack line. The replay selector lives on its own
|
||||
/// hub tab now — see [`spawn_replays_body`]. All markers
|
||||
/// (`StatsCell`, `PerModeBestsRow`, `StatsScrollable`) are unchanged.
|
||||
pub(crate) fn spawn_stats_body(
|
||||
card: &mut ChildSpawnerCommands,
|
||||
stats: &StatsSnapshot,
|
||||
progress: Option<&PlayerProgress>,
|
||||
time_attack: Option<&TimeAttackResource>,
|
||||
font_res: Option<&FontResource>,
|
||||
replays: &[Replay],
|
||||
selected_index: usize,
|
||||
) {
|
||||
// --- primary stat cells ---
|
||||
// First-launch zero-state: when no games have been played yet, render
|
||||
@@ -756,9 +699,7 @@ fn spawn_stats_screen(
|
||||
..default()
|
||||
};
|
||||
|
||||
let scrim = spawn_modal(commands, StatsScreen, Z_MODAL_PANEL, |card| {
|
||||
spawn_modal_header(card, "Statistics", font_res);
|
||||
|
||||
{
|
||||
// Scrollable body — the Stats panel renders an 8-cell grid plus
|
||||
// multiple sections (per-mode bests, progression, weekly goals,
|
||||
// unlocks, optional Time Attack, latest replay caption) and
|
||||
@@ -935,7 +876,45 @@ fn spawn_stats_screen(
|
||||
TextColor(STATE_WARNING),
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the Replays tab body inside the You hub's card: the
|
||||
/// Prev/Next selector, detail line, and the Watch / Copy-share-link
|
||||
/// actions (buttons live in the body now — the hub owns the single
|
||||
/// Done in the action row). All markers (`ReplayPrevButton`,
|
||||
/// `ReplayNextButton`, `ReplaySelectorCaption`, `ReplaySelectorDetail`,
|
||||
/// `WatchReplayButton`, `CopyShareLinkButton`) are unchanged, so the
|
||||
/// selector/watch/copy systems keep working.
|
||||
pub(crate) fn spawn_replays_body(
|
||||
card: &mut ChildSpawnerCommands,
|
||||
replays: &[Replay],
|
||||
selected_index: usize,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
|
||||
let font_row = TextFont {
|
||||
font: font_handle,
|
||||
font_size: TYPE_BODY,
|
||||
..default()
|
||||
};
|
||||
|
||||
{
|
||||
// Scrollable body — mirrors the other hub tabs so short
|
||||
// windows can still reach every control.
|
||||
card.spawn((
|
||||
StatsScrollable,
|
||||
ScrollPosition::default(),
|
||||
Node {
|
||||
flex_direction: FlexDirection::Column,
|
||||
row_gap: VAL_SPACE_3,
|
||||
max_height: Val::Vh(70.0),
|
||||
overflow: Overflow::scroll_y(),
|
||||
..default()
|
||||
},
|
||||
))
|
||||
.with_children(|body| {
|
||||
// --- Replay selector ---
|
||||
// Prev / Next chips step through the full replay history;
|
||||
// `repaint_replay_selector_caption` and
|
||||
@@ -1019,12 +998,21 @@ fn spawn_stats_screen(
|
||||
));
|
||||
});
|
||||
|
||||
spawn_modal_actions(card, |actions| {
|
||||
// The Watch Replay button is always rendered so the
|
||||
// affordance is discoverable from a fresh install. When no
|
||||
// replay exists, the click handler surfaces a clear
|
||||
// "No replay recorded yet" toast rather than silently
|
||||
// doing nothing.
|
||||
// Tab-local actions. The Watch Replay button is always
|
||||
// rendered so the affordance is discoverable from a fresh
|
||||
// install — with no replay, its click handler surfaces a
|
||||
// "No replay recorded yet" toast rather than silently doing
|
||||
// nothing. Same policy for Copy share link (toast explains
|
||||
// when no shareable upload exists).
|
||||
card.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
flex_wrap: FlexWrap::Wrap,
|
||||
column_gap: VAL_SPACE_2,
|
||||
row_gap: VAL_SPACE_2,
|
||||
margin: UiRect::top(VAL_SPACE_2),
|
||||
..default()
|
||||
})
|
||||
.with_children(|actions| {
|
||||
spawn_modal_button(
|
||||
actions,
|
||||
WatchReplayButton,
|
||||
@@ -1033,11 +1021,6 @@ fn spawn_stats_screen(
|
||||
ButtonVariant::Secondary,
|
||||
font_res,
|
||||
);
|
||||
// Copy share link only renders when a sharable URL is in
|
||||
// hand. The button is intentionally absent (rather than
|
||||
// disabled) when no upload has happened yet — keeps the
|
||||
// action bar free of dead controls in the local-only and
|
||||
// first-launch cases.
|
||||
spawn_modal_button(
|
||||
actions,
|
||||
CopyShareLinkButton,
|
||||
@@ -1046,18 +1029,8 @@ fn spawn_stats_screen(
|
||||
ButtonVariant::Secondary,
|
||||
font_res,
|
||||
);
|
||||
spawn_modal_button(
|
||||
actions,
|
||||
StatsCloseButton,
|
||||
"Done",
|
||||
Some("S"),
|
||||
ButtonVariant::Primary,
|
||||
font_res,
|
||||
);
|
||||
});
|
||||
});
|
||||
// Stats is read-only — opt into click-outside-to-dismiss.
|
||||
commands.entity(scrim).insert(ScrimDismissible);
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn one row of the "Per-mode bests" section: the mode label on the
|
||||
@@ -1290,6 +1263,7 @@ mod tests {
|
||||
// ProgressResource is an optional dependency for the stats screen;
|
||||
// include it so toggle tests exercise the progression panel.
|
||||
app.add_plugins(crate::progress_plugin::ProgressPlugin::headless());
|
||||
app.add_plugins(crate::you_hub_plugin::YouHubPlugin);
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
@@ -1832,6 +1806,25 @@ mod tests {
|
||||
// Prev/Next replay selector spawn-site tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Opens the You hub via `S`, then switches to the Replays tab
|
||||
/// (where the selector lives since Phase E) and settles the rebuild.
|
||||
fn open_replays_tab(app: &mut App) {
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::KeyS);
|
||||
app.update();
|
||||
// MinimalPlugins never clears input, so the sticky `just_pressed`
|
||||
// would keep re-requesting the Stats tab every frame.
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.clear();
|
||||
app.world_mut()
|
||||
.resource_mut::<crate::you_hub_plugin::ActiveYouTab>()
|
||||
.0 = crate::you_hub_plugin::YouTab::Replays;
|
||||
app.update();
|
||||
app.update();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selector_row_spawns_when_stats_screen_opens() {
|
||||
let mut app = headless_app();
|
||||
@@ -1840,10 +1833,7 @@ mod tests {
|
||||
let mut hist = app.world_mut().resource_mut::<ReplayHistoryResource>();
|
||||
hist.0.replays.push(make_test_replay(90, None));
|
||||
}
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::KeyS);
|
||||
app.update();
|
||||
open_replays_tab(&mut app);
|
||||
|
||||
let prev = app
|
||||
.world_mut()
|
||||
@@ -1878,10 +1868,7 @@ mod tests {
|
||||
let mut hist = app.world_mut().resource_mut::<ReplayHistoryResource>();
|
||||
hist.0.replays.push(make_test_replay(120, None));
|
||||
}
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::KeyS);
|
||||
app.update();
|
||||
open_replays_tab(&mut app);
|
||||
|
||||
let mut q = app
|
||||
.world_mut()
|
||||
@@ -1901,10 +1888,7 @@ mod tests {
|
||||
let mut hist = app.world_mut().resource_mut::<ReplayHistoryResource>();
|
||||
hist.0.replays.push(make_test_replay(65, None)); // 65s → "1:05"
|
||||
}
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::KeyS);
|
||||
app.update();
|
||||
open_replays_tab(&mut app);
|
||||
|
||||
let mut q = app
|
||||
.world_mut()
|
||||
|
||||
@@ -60,9 +60,9 @@ use crate::settings_plugin::SettingsResource;
|
||||
use crate::ui_theme::{
|
||||
ACCENT_PRIMARY, ACCENT_PRIMARY_HOVER, ACCENT_SECONDARY, BG_BASE, BG_ELEVATED, BG_ELEVATED_HI,
|
||||
BG_ELEVATED_PRESSED, BG_ELEVATED_TOP, BORDER_STRONG, BORDER_SUBTLE, HighContrastBorder,
|
||||
MOTION_MODAL_SECS, RADIUS_LG, RADIUS_MD, SCRIM, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY_LG,
|
||||
TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, VAL_SPACE_5,
|
||||
scaled_duration,
|
||||
MOTION_MODAL_SECS, RADIUS_LG, RADIUS_MD, RADIUS_SM, SCRIM, STATE_SUCCESS, TEXT_PRIMARY,
|
||||
TEXT_SECONDARY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3,
|
||||
VAL_SPACE_4, VAL_SPACE_5, scaled_duration,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -402,6 +402,46 @@ pub fn spawn_modal_button<M: Component>(
|
||||
});
|
||||
}
|
||||
|
||||
/// One tab chip for a tabbed modal (Settings, the You hub). The active
|
||||
/// chip is filled + bright with a success-green border; inactive chips
|
||||
/// are quiet outlines. `marker` is the plugin's click-target component
|
||||
/// carrying which tab the chip selects.
|
||||
pub fn spawn_tab_chip<M: Component>(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
marker: M,
|
||||
label: &str,
|
||||
active: bool,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
let font = TextFont {
|
||||
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
};
|
||||
parent
|
||||
.spawn((
|
||||
marker,
|
||||
Button,
|
||||
Node {
|
||||
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2),
|
||||
justify_content: JustifyContent::Center,
|
||||
border: UiRect::all(Val::Px(1.0)),
|
||||
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(if active { BG_ELEVATED_HI } else { BG_BASE }),
|
||||
BorderColor::all(if active { STATE_SUCCESS } else { BORDER_SUBTLE }),
|
||||
HighContrastBorder::with_default(if active { STATE_SUCCESS } else { BORDER_SUBTLE }),
|
||||
))
|
||||
.with_children(|b| {
|
||||
b.spawn((
|
||||
Text::new(label),
|
||||
font,
|
||||
TextColor(if active { TEXT_PRIMARY } else { TEXT_SECONDARY }),
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generic touch-scroll helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user