feat(engine): You hub — Profile/Stats/Achievements/Replays in one tabbed modal (Phase E) #161

Merged
funman300 merged 2 commits from feat/you-hub into master 2026-07-09 05:02:08 +00:00
8 changed files with 672 additions and 309 deletions
+20 -69
View File
@@ -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>>();
+1
View File
@@ -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)
+2
View File
@@ -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};
+19 -102
View File
@@ -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
+11 -29
View File
@@ -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.
+90 -106
View File
@@ -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()
+43 -3
View File
@@ -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
// ---------------------------------------------------------------------------
+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"
);
}
}