From 99064ce8087274c2a80a282b6c158746ad50f8ed Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 30 Apr 2026 01:36:33 +0000 Subject: [PATCH] feat(engine): convert ProfileScreen to modal scaffold + Done button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- solitaire_engine/src/profile_plugin.rs | 368 +++++++++++++------------ 1 file changed, 195 insertions(+), 173 deletions(-) diff --git a/solitaire_engine/src/profile_plugin.rs b/solitaire_engine/src/profile_plugin.rs index 8422870..678f1df 100644 --- a/solitaire_engine/src/profile_plugin.rs +++ b/solitaire_engine/src/profile_plugin.rs @@ -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::() - .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, Changed)>, + screens: Query>, +) { + 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>, achievements: Option>, stats: Option>, + font_res: Option>, screens: Query>, ) { 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() }); }