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:
@@ -11,10 +11,18 @@ use solitaire_data::SyncBackend;
|
||||
|
||||
use crate::achievement_plugin::AchievementsResource;
|
||||
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::{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.
|
||||
#[derive(Component, Debug)]
|
||||
@@ -23,10 +31,27 @@ pub struct ProfileScreen;
|
||||
/// Registers the `P` key toggle for the profile overlay.
|
||||
pub struct ProfilePlugin;
|
||||
|
||||
/// Marker on the "Done" button inside the Profile modal.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ProfileCloseButton;
|
||||
|
||||
impl Plugin for ProfilePlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
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>>,
|
||||
achievements: Option<Res<AchievementsResource>>,
|
||||
stats: Option<Res<StatsResource>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
screens: Query<Entity, With<ProfileScreen>>,
|
||||
) {
|
||||
let button_clicked = requests.read().count() > 0;
|
||||
@@ -56,6 +82,7 @@ fn toggle_profile_screen(
|
||||
progress.as_deref(),
|
||||
achievements.as_deref(),
|
||||
stats.as_deref(),
|
||||
font_res.as_deref(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -67,192 +94,187 @@ fn spawn_profile_screen(
|
||||
progress: Option<&ProgressResource>,
|
||||
achievements: Option<&AchievementsResource>,
|
||||
stats: Option<&StatsResource>,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
commands
|
||||
.spawn((
|
||||
ProfileScreen,
|
||||
Node {
|
||||
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()
|
||||
},
|
||||
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.88)),
|
||||
ZIndex(200),
|
||||
))
|
||||
.with_children(|root| {
|
||||
// ── Title ────────────────────────────────────────────────────────
|
||||
root.spawn((
|
||||
Text::new("Profile"),
|
||||
TextFont { font_size: 28.0, ..default() },
|
||||
TextColor(Color::srgb(1.0, 0.85, 0.3)),
|
||||
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
|
||||
let font_section = TextFont {
|
||||
font: font_handle.clone(),
|
||||
font_size: TYPE_BODY_LG,
|
||||
..default()
|
||||
};
|
||||
let font_row = TextFont {
|
||||
font: font_handle,
|
||||
font_size: TYPE_BODY,
|
||||
..default()
|
||||
};
|
||||
|
||||
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 {
|
||||
let (backend_name, username) = sync_info(&s.0.sync_backend);
|
||||
card.spawn((
|
||||
Text::new(format!("Account: {username} | Backend: {backend_name}")),
|
||||
font_row.clone(),
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
|
||||
// ── Sync section ─────────────────────────────────────────────────
|
||||
if let Some(s) = settings {
|
||||
let (backend_name, username) = sync_info(&s.0.sync_backend);
|
||||
root.spawn((
|
||||
Text::new(format!("Account: {username} | Backend: {backend_name}")),
|
||||
TextFont { font_size: 17.0, ..default() },
|
||||
TextColor(Color::srgb(0.7, 0.9, 1.0)),
|
||||
));
|
||||
}
|
||||
if let Some(ss) = sync_status {
|
||||
let status_text = match &ss.0 {
|
||||
SyncStatus::Idle => "Sync: idle".to_string(),
|
||||
SyncStatus::Syncing => "Sync: syncing\u{2026}".to_string(),
|
||||
SyncStatus::LastSynced(dt) => {
|
||||
format!("Last synced: {}", dt.format("%Y-%m-%d %H:%M"))
|
||||
}
|
||||
SyncStatus::Error(e) => format!("Sync error: {e}"),
|
||||
};
|
||||
root.spawn((
|
||||
Text::new(status_text),
|
||||
TextFont { font_size: 15.0, ..default() },
|
||||
TextColor(Color::srgb(0.7, 0.7, 0.7)),
|
||||
));
|
||||
}
|
||||
|
||||
// ── Progression section ───────────────────────────────────────────
|
||||
spawn_spacer(root, 4.0);
|
||||
root.spawn((
|
||||
Text::new("Progression"),
|
||||
TextFont { font_size: 22.0, ..default() },
|
||||
TextColor(Color::srgb(0.8, 0.9, 0.8)),
|
||||
));
|
||||
if let Some(p) = progress {
|
||||
let prog = &p.0;
|
||||
let (xp_span, xp_done) = xp_progress(prog.total_xp, prog.level);
|
||||
let pct = if xp_span == 0 {
|
||||
100u64
|
||||
} else {
|
||||
xp_done.saturating_mul(100).checked_div(xp_span).unwrap_or(100)
|
||||
};
|
||||
root.spawn((
|
||||
Text::new(format!(
|
||||
"Level {} \u{2014} {} XP ({}/{} to next, {}%)",
|
||||
prog.level, prog.total_xp, xp_done, xp_span, pct
|
||||
)),
|
||||
TextFont { font_size: 17.0, ..default() },
|
||||
TextColor(Color::srgb(0.85, 0.85, 0.85)),
|
||||
));
|
||||
root.spawn((
|
||||
Text::new(format!(
|
||||
"Daily streak: {} | Card backs: {} | Backgrounds: {}",
|
||||
prog.daily_challenge_streak,
|
||||
prog.unlocked_card_backs.len(),
|
||||
prog.unlocked_backgrounds.len(),
|
||||
)),
|
||||
TextFont { font_size: 17.0, ..default() },
|
||||
TextColor(Color::srgb(0.85, 0.85, 0.85)),
|
||||
));
|
||||
}
|
||||
|
||||
// ── Achievements section ──────────────────────────────────────────
|
||||
spawn_spacer(root, 4.0);
|
||||
root.spawn((
|
||||
Text::new("Achievements"),
|
||||
TextFont { font_size: 22.0, ..default() },
|
||||
TextColor(Color::srgb(0.8, 0.9, 0.8)),
|
||||
));
|
||||
if let Some(ar) = achievements {
|
||||
let records = &ar.0;
|
||||
let unlocked_count = records.iter().filter(|r| r.unlocked).count();
|
||||
root.spawn((
|
||||
Text::new(format!("{} / 18 unlocked", unlocked_count)),
|
||||
TextFont { font_size: 17.0, ..default() },
|
||||
TextColor(Color::srgb(1.0, 0.85, 0.4)),
|
||||
));
|
||||
|
||||
let mut any_unlocked = false;
|
||||
for record in records {
|
||||
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);
|
||||
if is_secret && !record.unlocked {
|
||||
continue;
|
||||
}
|
||||
if !record.unlocked {
|
||||
continue;
|
||||
}
|
||||
any_unlocked = true;
|
||||
let name = def.map(|d| d.name).unwrap_or(record.id.as_str());
|
||||
let date_str = match record.unlock_date {
|
||||
Some(dt) => format!(" ({})", dt.format("%Y-%m-%d")),
|
||||
None => String::new(),
|
||||
};
|
||||
root.spawn((
|
||||
Text::new(format!(" [x] {name}{date_str}")),
|
||||
TextFont { font_size: 14.0, ..default() },
|
||||
TextColor(Color::srgb(0.7, 1.0, 0.7)),
|
||||
));
|
||||
}
|
||||
if let Some(ss) = sync_status {
|
||||
let status_text = match &ss.0 {
|
||||
SyncStatus::Idle => "Sync: idle".to_string(),
|
||||
SyncStatus::Syncing => "Sync: syncing\u{2026}".to_string(),
|
||||
SyncStatus::LastSynced(dt) => {
|
||||
format!("Last synced: {}", dt.format("%Y-%m-%d %H:%M"))
|
||||
}
|
||||
if !any_unlocked {
|
||||
root.spawn((
|
||||
Text::new(" No achievements unlocked yet."),
|
||||
TextFont { font_size: 14.0, ..default() },
|
||||
TextColor(Color::srgb(0.7, 0.7, 0.7)),
|
||||
));
|
||||
SyncStatus::Error(e) => format!("Sync error: {e}"),
|
||||
};
|
||||
card.spawn((
|
||||
Text::new(status_text),
|
||||
font_row.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
}
|
||||
|
||||
// ── Progression section ─────────────────────────────────────
|
||||
spawn_spacer(card, VAL_SPACE_2);
|
||||
card.spawn((
|
||||
Text::new("Progression"),
|
||||
font_section.clone(),
|
||||
TextColor(STATE_INFO),
|
||||
));
|
||||
if let Some(p) = progress {
|
||||
let prog = &p.0;
|
||||
let (xp_span, xp_done) = xp_progress(prog.total_xp, prog.level);
|
||||
let pct = if xp_span == 0 {
|
||||
100u64
|
||||
} else {
|
||||
xp_done.saturating_mul(100).checked_div(xp_span).unwrap_or(100)
|
||||
};
|
||||
card.spawn((
|
||||
Text::new(format!(
|
||||
"Level {} \u{2014} {} XP ({}/{} to next, {}%)",
|
||||
prog.level, prog.total_xp, xp_done, xp_span, pct
|
||||
)),
|
||||
font_row.clone(),
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
card.spawn((
|
||||
Text::new(format!(
|
||||
"Daily streak: {} | Card backs: {} | Backgrounds: {}",
|
||||
prog.daily_challenge_streak,
|
||||
prog.unlocked_card_backs.len(),
|
||||
prog.unlocked_backgrounds.len(),
|
||||
)),
|
||||
font_row.clone(),
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
}
|
||||
|
||||
// ── Achievements section ────────────────────────────────────
|
||||
spawn_spacer(card, VAL_SPACE_2);
|
||||
card.spawn((
|
||||
Text::new("Achievements"),
|
||||
font_section.clone(),
|
||||
TextColor(STATE_INFO),
|
||||
));
|
||||
if let Some(ar) = achievements {
|
||||
let records = &ar.0;
|
||||
let unlocked_count = records.iter().filter(|r| r.unlocked).count();
|
||||
card.spawn((
|
||||
Text::new(format!("{} / 18 unlocked", unlocked_count)),
|
||||
font_row.clone(),
|
||||
TextColor(ACCENT_PRIMARY),
|
||||
));
|
||||
|
||||
let mut any_unlocked = false;
|
||||
for record in records {
|
||||
let def = achievement_by_id(record.id.as_str());
|
||||
let is_secret = def.map(|d| d.secret).unwrap_or(false);
|
||||
if is_secret && !record.unlocked {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Statistics summary section ────────────────────────────────────
|
||||
spawn_spacer(root, 4.0);
|
||||
root.spawn((
|
||||
Text::new("Statistics Summary"),
|
||||
TextFont { font_size: 22.0, ..default() },
|
||||
TextColor(Color::srgb(0.8, 0.9, 0.8)),
|
||||
));
|
||||
if let Some(sr) = stats {
|
||||
let s = &sr.0;
|
||||
let best_score_str = if s.best_single_score == 0 {
|
||||
"\u{2014}".to_string()
|
||||
} else {
|
||||
s.best_single_score.to_string()
|
||||
if !record.unlocked {
|
||||
continue;
|
||||
}
|
||||
any_unlocked = true;
|
||||
let name = def.map(|d| d.name).unwrap_or(record.id.as_str());
|
||||
let date_str = match record.unlock_date {
|
||||
Some(dt) => format!(" ({})", dt.format("%Y-%m-%d")),
|
||||
None => String::new(),
|
||||
};
|
||||
root.spawn((
|
||||
Text::new(format!(
|
||||
"Played: {} | Won: {} | Win rate: {} | Best time: {}",
|
||||
s.games_played,
|
||||
s.games_won,
|
||||
format_win_rate(s),
|
||||
format_fastest_win(s.fastest_win_seconds),
|
||||
)),
|
||||
TextFont { font_size: 16.0, ..default() },
|
||||
TextColor(Color::srgb(0.85, 0.85, 0.85)),
|
||||
));
|
||||
root.spawn((
|
||||
Text::new(format!(
|
||||
"Win streak: {} current, {} best | Best score: {}",
|
||||
s.win_streak_current, s.win_streak_best, best_score_str,
|
||||
)),
|
||||
TextFont { font_size: 16.0, ..default() },
|
||||
TextColor(Color::srgb(0.85, 0.85, 0.85)),
|
||||
card.spawn((
|
||||
Text::new(format!(" [x] {name}{date_str}")),
|
||||
font_row.clone(),
|
||||
TextColor(STATE_SUCCESS),
|
||||
));
|
||||
}
|
||||
if !any_unlocked {
|
||||
card.spawn((
|
||||
Text::new(" No achievements unlocked yet."),
|
||||
font_row.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Dismiss hint ──────────────────────────────────────────────────
|
||||
spawn_spacer(root, 8.0);
|
||||
root.spawn((
|
||||
Text::new("Press P to close"),
|
||||
TextFont { font_size: 16.0, ..default() },
|
||||
TextColor(Color::srgb(0.55, 0.55, 0.55)),
|
||||
// ── Statistics summary section ──────────────────────────────
|
||||
spawn_spacer(card, VAL_SPACE_2);
|
||||
card.spawn((
|
||||
Text::new("Statistics Summary"),
|
||||
font_section.clone(),
|
||||
TextColor(STATE_INFO),
|
||||
));
|
||||
if let Some(sr) = stats {
|
||||
let s = &sr.0;
|
||||
let best_score_str = if s.best_single_score == 0 {
|
||||
"\u{2014}".to_string()
|
||||
} else {
|
||||
s.best_single_score.to_string()
|
||||
};
|
||||
card.spawn((
|
||||
Text::new(format!(
|
||||
"Played: {} | Won: {} | Win rate: {} | Best time: {}",
|
||||
s.games_played,
|
||||
s.games_won,
|
||||
format_win_rate(s),
|
||||
format_fastest_win(s.fastest_win_seconds),
|
||||
)),
|
||||
font_row.clone(),
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
card.spawn((
|
||||
Text::new(format!(
|
||||
"Win streak: {} current, {} best | Best score: {}",
|
||||
s.win_streak_current, s.win_streak_best, best_score_str,
|
||||
)),
|
||||
font_row.clone(),
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
}
|
||||
|
||||
spawn_modal_actions(card, |actions| {
|
||||
spawn_modal_button(
|
||||
actions,
|
||||
ProfileCloseButton,
|
||||
"Done",
|
||||
Some("P"),
|
||||
ButtonVariant::Primary,
|
||||
font_res,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
height: Val::Px(height_px),
|
||||
height,
|
||||
..default()
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user