feat(engine): convert ProfileScreen to modal scaffold + Done button

Phase 3 step 5d of the UX overhaul. Wraps the profile sections (Sync,
Progression, Achievements, Statistics Summary) in the standard modal
scaffold; replaces every inline colour with a ui_theme token; adds an
explicit "Sync" section header so the four sections all read in the
same shape; replaces the "Press P to close" prose hint with a primary
Done button.

The previous bare full-screen scrim + inline-text approach was on the
audit's "feels like a debug panel" list — same fix as Stats /
Achievements / Help.

Section headers now use STATE_INFO at TYPE_BODY_LG, body lines use
TEXT_PRIMARY at TYPE_BODY, secondary lines (sync status, "no
achievements yet") use TEXT_SECONDARY. The achievement-count line
adopts ACCENT_PRIMARY (yellow) and unlocked-achievement entries use
STATE_SUCCESS (green) — same colour vocabulary the Achievements
overlay uses.

The unused `spawn_spacer` helper now takes a `Val` so callers can
pass spacing-token constants directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-04-30 01:36:33 +00:00
parent de4dba6f98
commit 99064ce808
+102 -80
View File
@@ -11,10 +11,18 @@ use solitaire_data::SyncBackend;
use crate::achievement_plugin::AchievementsResource; use crate::achievement_plugin::AchievementsResource;
use crate::events::ToggleProfileRequestEvent; use crate::events::ToggleProfileRequestEvent;
use crate::font_plugin::FontResource;
use crate::progress_plugin::ProgressResource; use crate::progress_plugin::ProgressResource;
use crate::resources::{SyncStatus, SyncStatusResource}; use crate::resources::{SyncStatus, SyncStatusResource};
use crate::settings_plugin::SettingsResource; use crate::settings_plugin::SettingsResource;
use crate::stats_plugin::{format_fastest_win, format_win_rate, StatsResource}; use crate::stats_plugin::{format_fastest_win, format_win_rate, StatsResource};
use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
};
use crate::ui_theme::{
ACCENT_PRIMARY, STATE_INFO, STATE_SUCCESS, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
TYPE_BODY_LG, VAL_SPACE_2, Z_MODAL_PANEL,
};
/// Marker component on the profile overlay root node. /// Marker component on the profile overlay root node.
#[derive(Component, Debug)] #[derive(Component, Debug)]
@@ -23,10 +31,27 @@ pub struct ProfileScreen;
/// Registers the `P` key toggle for the profile overlay. /// Registers the `P` key toggle for the profile overlay.
pub struct ProfilePlugin; pub struct ProfilePlugin;
/// Marker on the "Done" button inside the Profile modal.
#[derive(Component, Debug)]
pub struct ProfileCloseButton;
impl Plugin for ProfilePlugin { impl Plugin for ProfilePlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.add_message::<ToggleProfileRequestEvent>() app.add_message::<ToggleProfileRequestEvent>()
.add_systems(Update, toggle_profile_screen); .add_systems(Update, (toggle_profile_screen, handle_profile_close_button));
}
}
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();
} }
} }
@@ -40,6 +65,7 @@ fn toggle_profile_screen(
progress: Option<Res<ProgressResource>>, progress: Option<Res<ProgressResource>>,
achievements: Option<Res<AchievementsResource>>, achievements: Option<Res<AchievementsResource>>,
stats: Option<Res<StatsResource>>, stats: Option<Res<StatsResource>>,
font_res: Option<Res<FontResource>>,
screens: Query<Entity, With<ProfileScreen>>, screens: Query<Entity, With<ProfileScreen>>,
) { ) {
let button_clicked = requests.read().count() > 0; let button_clicked = requests.read().count() > 0;
@@ -56,6 +82,7 @@ fn toggle_profile_screen(
progress.as_deref(), progress.as_deref(),
achievements.as_deref(), achievements.as_deref(),
stats.as_deref(), stats.as_deref(),
font_res.as_deref(),
); );
} }
} }
@@ -67,42 +94,35 @@ fn spawn_profile_screen(
progress: Option<&ProgressResource>, progress: Option<&ProgressResource>,
achievements: Option<&AchievementsResource>, achievements: Option<&AchievementsResource>,
stats: Option<&StatsResource>, stats: Option<&StatsResource>,
font_res: Option<&FontResource>,
) { ) {
commands let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
.spawn(( let font_section = TextFont {
ProfileScreen, font: font_handle.clone(),
Node { font_size: TYPE_BODY_LG,
position_type: PositionType::Absolute,
left: Val::Percent(0.0),
top: Val::Percent(0.0),
width: Val::Percent(100.0),
height: Val::Percent(100.0),
flex_direction: FlexDirection::Column,
justify_content: JustifyContent::FlexStart,
align_items: AlignItems::Center,
row_gap: Val::Px(4.0),
padding: UiRect::all(Val::Px(24.0)),
overflow: Overflow::clip(),
..default() ..default()
}, };
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.88)), let font_row = TextFont {
ZIndex(200), font: font_handle,
)) font_size: TYPE_BODY,
.with_children(|root| { ..default()
// ── Title ──────────────────────────────────────────────────────── };
root.spawn((
Text::new("Profile"),
TextFont { font_size: 28.0, ..default() },
TextColor(Color::srgb(1.0, 0.85, 0.3)),
));
// ── Sync section ───────────────────────────────────────────────── spawn_modal(commands, ProfileScreen, Z_MODAL_PANEL, |card| {
spawn_modal_header(card, "Profile", font_res);
// ── Sync section ────────────────────────────────────────────
card.spawn((
Text::new("Sync"),
font_section.clone(),
TextColor(STATE_INFO),
));
if let Some(s) = settings { if let Some(s) = settings {
let (backend_name, username) = sync_info(&s.0.sync_backend); let (backend_name, username) = sync_info(&s.0.sync_backend);
root.spawn(( card.spawn((
Text::new(format!("Account: {username} | Backend: {backend_name}")), Text::new(format!("Account: {username} | Backend: {backend_name}")),
TextFont { font_size: 17.0, ..default() }, font_row.clone(),
TextColor(Color::srgb(0.7, 0.9, 1.0)), TextColor(TEXT_PRIMARY),
)); ));
} }
if let Some(ss) = sync_status { if let Some(ss) = sync_status {
@@ -114,19 +134,19 @@ fn spawn_profile_screen(
} }
SyncStatus::Error(e) => format!("Sync error: {e}"), SyncStatus::Error(e) => format!("Sync error: {e}"),
}; };
root.spawn(( card.spawn((
Text::new(status_text), Text::new(status_text),
TextFont { font_size: 15.0, ..default() }, font_row.clone(),
TextColor(Color::srgb(0.7, 0.7, 0.7)), TextColor(TEXT_SECONDARY),
)); ));
} }
// ── Progression section ─────────────────────────────────────────── // ── Progression section ─────────────────────────────────────
spawn_spacer(root, 4.0); spawn_spacer(card, VAL_SPACE_2);
root.spawn(( card.spawn((
Text::new("Progression"), Text::new("Progression"),
TextFont { font_size: 22.0, ..default() }, font_section.clone(),
TextColor(Color::srgb(0.8, 0.9, 0.8)), TextColor(STATE_INFO),
)); ));
if let Some(p) = progress { if let Some(p) = progress {
let prog = &p.0; let prog = &p.0;
@@ -136,46 +156,45 @@ fn spawn_profile_screen(
} else { } else {
xp_done.saturating_mul(100).checked_div(xp_span).unwrap_or(100) xp_done.saturating_mul(100).checked_div(xp_span).unwrap_or(100)
}; };
root.spawn(( card.spawn((
Text::new(format!( Text::new(format!(
"Level {} \u{2014} {} XP ({}/{} to next, {}%)", "Level {} \u{2014} {} XP ({}/{} to next, {}%)",
prog.level, prog.total_xp, xp_done, xp_span, pct prog.level, prog.total_xp, xp_done, xp_span, pct
)), )),
TextFont { font_size: 17.0, ..default() }, font_row.clone(),
TextColor(Color::srgb(0.85, 0.85, 0.85)), TextColor(TEXT_PRIMARY),
)); ));
root.spawn(( card.spawn((
Text::new(format!( Text::new(format!(
"Daily streak: {} | Card backs: {} | Backgrounds: {}", "Daily streak: {} | Card backs: {} | Backgrounds: {}",
prog.daily_challenge_streak, prog.daily_challenge_streak,
prog.unlocked_card_backs.len(), prog.unlocked_card_backs.len(),
prog.unlocked_backgrounds.len(), prog.unlocked_backgrounds.len(),
)), )),
TextFont { font_size: 17.0, ..default() }, font_row.clone(),
TextColor(Color::srgb(0.85, 0.85, 0.85)), TextColor(TEXT_PRIMARY),
)); ));
} }
// ── Achievements section ────────────────────────────────────────── // ── Achievements section ────────────────────────────────────
spawn_spacer(root, 4.0); spawn_spacer(card, VAL_SPACE_2);
root.spawn(( card.spawn((
Text::new("Achievements"), Text::new("Achievements"),
TextFont { font_size: 22.0, ..default() }, font_section.clone(),
TextColor(Color::srgb(0.8, 0.9, 0.8)), TextColor(STATE_INFO),
)); ));
if let Some(ar) = achievements { if let Some(ar) = achievements {
let records = &ar.0; let records = &ar.0;
let unlocked_count = records.iter().filter(|r| r.unlocked).count(); let unlocked_count = records.iter().filter(|r| r.unlocked).count();
root.spawn(( card.spawn((
Text::new(format!("{} / 18 unlocked", unlocked_count)), Text::new(format!("{} / 18 unlocked", unlocked_count)),
TextFont { font_size: 17.0, ..default() }, font_row.clone(),
TextColor(Color::srgb(1.0, 0.85, 0.4)), TextColor(ACCENT_PRIMARY),
)); ));
let mut any_unlocked = false; let mut any_unlocked = false;
for record in records { for record in records {
let def = achievement_by_id(record.id.as_str()); let def = achievement_by_id(record.id.as_str());
// Skip secret achievements that are not unlocked.
let is_secret = def.map(|d| d.secret).unwrap_or(false); let is_secret = def.map(|d| d.secret).unwrap_or(false);
if is_secret && !record.unlocked { if is_secret && !record.unlocked {
continue; continue;
@@ -189,27 +208,27 @@ fn spawn_profile_screen(
Some(dt) => format!(" ({})", dt.format("%Y-%m-%d")), Some(dt) => format!(" ({})", dt.format("%Y-%m-%d")),
None => String::new(), None => String::new(),
}; };
root.spawn(( card.spawn((
Text::new(format!(" [x] {name}{date_str}")), Text::new(format!(" [x] {name}{date_str}")),
TextFont { font_size: 14.0, ..default() }, font_row.clone(),
TextColor(Color::srgb(0.7, 1.0, 0.7)), TextColor(STATE_SUCCESS),
)); ));
} }
if !any_unlocked { if !any_unlocked {
root.spawn(( card.spawn((
Text::new(" No achievements unlocked yet."), Text::new(" No achievements unlocked yet."),
TextFont { font_size: 14.0, ..default() }, font_row.clone(),
TextColor(Color::srgb(0.7, 0.7, 0.7)), TextColor(TEXT_SECONDARY),
)); ));
} }
} }
// ── Statistics summary section ──────────────────────────────────── // ── Statistics summary section ──────────────────────────────
spawn_spacer(root, 4.0); spawn_spacer(card, VAL_SPACE_2);
root.spawn(( card.spawn((
Text::new("Statistics Summary"), Text::new("Statistics Summary"),
TextFont { font_size: 22.0, ..default() }, font_section.clone(),
TextColor(Color::srgb(0.8, 0.9, 0.8)), TextColor(STATE_INFO),
)); ));
if let Some(sr) = stats { if let Some(sr) = stats {
let s = &sr.0; let s = &sr.0;
@@ -218,7 +237,7 @@ fn spawn_profile_screen(
} else { } else {
s.best_single_score.to_string() s.best_single_score.to_string()
}; };
root.spawn(( card.spawn((
Text::new(format!( Text::new(format!(
"Played: {} | Won: {} | Win rate: {} | Best time: {}", "Played: {} | Won: {} | Win rate: {} | Best time: {}",
s.games_played, s.games_played,
@@ -226,33 +245,36 @@ fn spawn_profile_screen(
format_win_rate(s), format_win_rate(s),
format_fastest_win(s.fastest_win_seconds), format_fastest_win(s.fastest_win_seconds),
)), )),
TextFont { font_size: 16.0, ..default() }, font_row.clone(),
TextColor(Color::srgb(0.85, 0.85, 0.85)), TextColor(TEXT_PRIMARY),
)); ));
root.spawn(( card.spawn((
Text::new(format!( Text::new(format!(
"Win streak: {} current, {} best | Best score: {}", "Win streak: {} current, {} best | Best score: {}",
s.win_streak_current, s.win_streak_best, best_score_str, s.win_streak_current, s.win_streak_best, best_score_str,
)), )),
TextFont { font_size: 16.0, ..default() }, font_row.clone(),
TextColor(Color::srgb(0.85, 0.85, 0.85)), TextColor(TEXT_PRIMARY),
)); ));
} }
// ── Dismiss hint ────────────────────────────────────────────────── spawn_modal_actions(card, |actions| {
spawn_spacer(root, 8.0); spawn_modal_button(
root.spawn(( actions,
Text::new("Press P to close"), ProfileCloseButton,
TextFont { font_size: 16.0, ..default() }, "Done",
TextColor(Color::srgb(0.55, 0.55, 0.55)), Some("P"),
)); ButtonVariant::Primary,
font_res,
);
});
}); });
} }
/// Spawn a fixed-height vertical spacer node. /// Spawn a fixed-height vertical spacer node.
fn spawn_spacer(parent: &mut ChildSpawnerCommands, height_px: f32) { fn spawn_spacer(parent: &mut ChildSpawnerCommands, height: Val) {
parent.spawn(Node { parent.spawn(Node {
height: Val::Px(height_px), height,
..default() ..default()
}); });
} }