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::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,192 +94,187 @@ 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,
|
..default()
|
||||||
left: Val::Percent(0.0),
|
};
|
||||||
top: Val::Percent(0.0),
|
let font_row = TextFont {
|
||||||
width: Val::Percent(100.0),
|
font: font_handle,
|
||||||
height: Val::Percent(100.0),
|
font_size: TYPE_BODY,
|
||||||
flex_direction: FlexDirection::Column,
|
..default()
|
||||||
justify_content: JustifyContent::FlexStart,
|
};
|
||||||
align_items: AlignItems::Center,
|
|
||||||
row_gap: Val::Px(4.0),
|
spawn_modal(commands, ProfileScreen, Z_MODAL_PANEL, |card| {
|
||||||
padding: UiRect::all(Val::Px(24.0)),
|
spawn_modal_header(card, "Profile", font_res);
|
||||||
overflow: Overflow::clip(),
|
|
||||||
..default()
|
// ── Sync section ────────────────────────────────────────────
|
||||||
},
|
card.spawn((
|
||||||
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.88)),
|
Text::new("Sync"),
|
||||||
ZIndex(200),
|
font_section.clone(),
|
||||||
))
|
TextColor(STATE_INFO),
|
||||||
.with_children(|root| {
|
));
|
||||||
// ── Title ────────────────────────────────────────────────────────
|
if let Some(s) = settings {
|
||||||
root.spawn((
|
let (backend_name, username) = sync_info(&s.0.sync_backend);
|
||||||
Text::new("Profile"),
|
card.spawn((
|
||||||
TextFont { font_size: 28.0, ..default() },
|
Text::new(format!("Account: {username} | Backend: {backend_name}")),
|
||||||
TextColor(Color::srgb(1.0, 0.85, 0.3)),
|
font_row.clone(),
|
||||||
|
TextColor(TEXT_PRIMARY),
|
||||||
));
|
));
|
||||||
|
}
|
||||||
// ── Sync section ─────────────────────────────────────────────────
|
if let Some(ss) = sync_status {
|
||||||
if let Some(s) = settings {
|
let status_text = match &ss.0 {
|
||||||
let (backend_name, username) = sync_info(&s.0.sync_backend);
|
SyncStatus::Idle => "Sync: idle".to_string(),
|
||||||
root.spawn((
|
SyncStatus::Syncing => "Sync: syncing\u{2026}".to_string(),
|
||||||
Text::new(format!("Account: {username} | Backend: {backend_name}")),
|
SyncStatus::LastSynced(dt) => {
|
||||||
TextFont { font_size: 17.0, ..default() },
|
format!("Last synced: {}", dt.format("%Y-%m-%d %H:%M"))
|
||||||
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 !any_unlocked {
|
SyncStatus::Error(e) => format!("Sync error: {e}"),
|
||||||
root.spawn((
|
};
|
||||||
Text::new(" No achievements unlocked yet."),
|
card.spawn((
|
||||||
TextFont { font_size: 14.0, ..default() },
|
Text::new(status_text),
|
||||||
TextColor(Color::srgb(0.7, 0.7, 0.7)),
|
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;
|
||||||
}
|
}
|
||||||
}
|
if !record.unlocked {
|
||||||
|
continue;
|
||||||
// ── Statistics summary section ────────────────────────────────────
|
}
|
||||||
spawn_spacer(root, 4.0);
|
any_unlocked = true;
|
||||||
root.spawn((
|
let name = def.map(|d| d.name).unwrap_or(record.id.as_str());
|
||||||
Text::new("Statistics Summary"),
|
let date_str = match record.unlock_date {
|
||||||
TextFont { font_size: 22.0, ..default() },
|
Some(dt) => format!(" ({})", dt.format("%Y-%m-%d")),
|
||||||
TextColor(Color::srgb(0.8, 0.9, 0.8)),
|
None => String::new(),
|
||||||
));
|
|
||||||
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()
|
|
||||||
};
|
};
|
||||||
root.spawn((
|
card.spawn((
|
||||||
Text::new(format!(
|
Text::new(format!(" [x] {name}{date_str}")),
|
||||||
"Played: {} | Won: {} | Win rate: {} | Best time: {}",
|
font_row.clone(),
|
||||||
s.games_played,
|
TextColor(STATE_SUCCESS),
|
||||||
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)),
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
if !any_unlocked {
|
||||||
|
card.spawn((
|
||||||
|
Text::new(" No achievements unlocked yet."),
|
||||||
|
font_row.clone(),
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Dismiss hint ──────────────────────────────────────────────────
|
// ── Statistics summary section ──────────────────────────────
|
||||||
spawn_spacer(root, 8.0);
|
spawn_spacer(card, VAL_SPACE_2);
|
||||||
root.spawn((
|
card.spawn((
|
||||||
Text::new("Press P to close"),
|
Text::new("Statistics Summary"),
|
||||||
TextFont { font_size: 16.0, ..default() },
|
font_section.clone(),
|
||||||
TextColor(Color::srgb(0.55, 0.55, 0.55)),
|
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.
|
/// 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()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user