feat(engine): wire avatar download and display into profile modal
Build and Deploy / build-and-push (push) Successful in 4m15s

- Add avatar_plugin: AvatarPlugin, AvatarResource, AvatarFetchEvent
  - After AvatarFetchEvent fires, spawns an async reqwest download task
  - On completion, decodes image bytes via image::load_from_memory →
    Image::from_dynamic and inserts into Assets<Image>
- Expand auth task to also call fetch_me_with_token immediately after
  login/register so avatar_url is available without a second round-trip
- poll_auth_task fires AvatarFetchEvent when avatar_url is Some, building
  the full URL from base_url + relative avatar path
- Profile modal shows 48px circular avatar ImageNode when AvatarResource
  is populated, or an initials disc (first letter of username) as fallback
- Add image = "0.25" and reqwest to solitaire_engine deps
- Add fetch_me_with_token helper to SolitaireServerClient for use when
  the access token hasn't been persisted to keychain yet

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-14 17:27:25 -07:00
parent 7177f0eb1b
commit 677999a51e
9 changed files with 226 additions and 13 deletions
+60 -5
View File
@@ -12,6 +12,7 @@ use solitaire_core::achievement::{achievement_by_id, ALL_ACHIEVEMENTS};
use solitaire_data::SyncBackend;
use crate::achievement_plugin::AchievementsResource;
use crate::avatar_plugin::AvatarResource;
use crate::events::ToggleProfileRequestEvent;
use crate::font_plugin::FontResource;
use crate::progress_plugin::ProgressResource;
@@ -143,6 +144,7 @@ fn toggle_profile_screen(
achievements: Option<Res<AchievementsResource>>,
stats: Option<Res<StatsResource>>,
font_res: Option<Res<FontResource>>,
avatar: Option<Res<AvatarResource>>,
screens: Query<Entity, With<ProfileScreen>>,
) {
let button_clicked = requests.read().count() > 0;
@@ -170,10 +172,12 @@ fn toggle_profile_screen(
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,
settings: Option<&SettingsResource>,
@@ -182,6 +186,7 @@ fn spawn_profile_screen(
achievements: Option<&AchievementsResource>,
stats: Option<&StatsResource>,
font_res: Option<&FontResource>,
avatar: Option<&AvatarResource>,
) {
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
let font_section = TextFont {
@@ -245,11 +250,61 @@ fn spawn_profile_screen(
));
if let Some(s) = settings {
let (backend_name, username) = sync_info(&s.0.sync_backend);
body.spawn((
Text::new(format!("Account: {username} | Backend: {backend_name}")),
font_row.clone(),
TextColor(TEXT_PRIMARY),
));
// Avatar row: image (if downloaded) or filled initials circle.
let avatar_handle = avatar.and_then(|a| a.0.clone());
body.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: Val::Px(10.0),
margin: UiRect { bottom: Val::Px(4.0), ..default() },
..default()
})
.with_children(|row| {
const SIZE: f32 = 48.0;
const RADIUS: f32 = 24.0;
if let Some(handle) = avatar_handle {
row.spawn((
ImageNode::new(handle),
Node {
width: Val::Px(SIZE),
height: Val::Px(SIZE),
border_radius: BorderRadius::all(Val::Px(RADIUS)),
..default()
},
));
} else {
// Initials fallback: coloured disc with the first letter.
let initial = username.chars().next().unwrap_or('?').to_uppercase().next().unwrap_or('?');
row.spawn((
Node {
width: Val::Px(SIZE),
height: Val::Px(SIZE),
border_radius: BorderRadius::all(Val::Px(RADIUS)),
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
..default()
},
BackgroundColor(BG_ELEVATED),
))
.with_children(|disc| {
disc.spawn((
Text::new(initial.to_string()),
TextFont {
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
font_size: 22.0,
..default()
},
TextColor(TEXT_SECONDARY),
));
});
}
row.spawn((
Text::new(format!("{username} | {backend_name}")),
font_row.clone(),
TextColor(TEXT_PRIMARY),
));
});
}
if let Some(ss) = sync_status {
let status_text = match &ss.0 {