feat(engine): You hub — Profile/Stats/Achievements/Replays in one tabbed modal
Test / test (pull_request) Failing after 8m41s
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user