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
Generated
+7
View File
@@ -4034,9 +4034,14 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"color_quant",
"gif",
"image-webp",
"moxcms",
"num-traits",
"png 0.18.1",
"zune-core",
"zune-jpeg",
]
[[package]]
@@ -7013,8 +7018,10 @@ dependencies = [
"bevy",
"chrono",
"dirs",
"image",
"jni 0.21.1",
"kira",
"reqwest",
"resvg",
"ron",
"serde",
+3
View File
@@ -110,6 +110,9 @@ ron = "0.12"
# only `deflate` is needed because the importer rejects other
# compression methods anyway (see Phase 7 spec).
zip = { version = "8.6", default-features = false, features = ["deflate"] }
# Image decoding for avatar bytes received from the server.
# Features mirror what Bevy already enables via bevy_image.
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] }
# Importer-only test dependency: tests build zip archives in a
# scratch directory so they don't pollute the real user themes path
+2 -1
View File
@@ -26,7 +26,7 @@ use bevy::winit::WinitWindows;
use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings};
use solitaire_engine::{
register_theme_asset_sources, AchievementPlugin, AnalyticsPlugin, AnimationPlugin, AssetSourcesPlugin,
AudioPlugin, AutoCompletePlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin,
AudioPlugin, AutoCompletePlugin, AvatarPlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin,
CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin,
FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
OnboardingPlugin, PausePlugin, PlayBySeedPlugin, ProfilePlugin, ProgressPlugin,
@@ -187,6 +187,7 @@ pub fn run() {
.add_plugins(HudPlugin)
.add_plugins(HelpPlugin)
.add_plugins(HomePlugin::default())
.add_plugins(AvatarPlugin)
.add_plugins(ProfilePlugin)
.add_plugins(PausePlugin)
.add_plugins(SettingsPlugin::default())
+15
View File
@@ -534,6 +534,21 @@ impl SolitaireServerClient {
Self::extract_me_body(resp).await
}
/// Like [`fetch_me`] but uses an explicit token instead of reading from the
/// OS keychain. Useful immediately after login/register when the token has
/// not yet been persisted.
pub async fn fetch_me_with_token(&self, token: &str) -> Result<(String, Option<String>), SyncError> {
let url = format!("{}/api/me", self.base_url);
let resp = self
.client
.get(&url)
.bearer_auth(token)
.send()
.await
.map_err(|e| SyncError::Network(e.to_string()))?;
Self::extract_me_body(resp).await
}
async fn extract_me_body(resp: reqwest::Response) -> Result<(String, Option<String>), SyncError> {
let status = resp.status();
if !status.is_success() {
+2
View File
@@ -6,6 +6,8 @@ edition.workspace = true
[dependencies]
bevy = { workspace = true }
image = { workspace = true }
reqwest = { workspace = true }
kira = { workspace = true }
solitaire_core = { workspace = true }
solitaire_data = { workspace = true }
+108
View File
@@ -0,0 +1,108 @@
//! Downloads and caches the player's server avatar for display in the
//! profile modal.
//!
//! # Flow
//!
//! 1. After a successful login/register, `sync_setup_plugin` fires
//! [`AvatarFetchEvent`] with the server base URL and the relative
//! avatar path (e.g. `/avatars/{uuid}.png`).
//! 2. [`handle_avatar_fetch`] spawns an async task on the
//! [`AsyncComputeTaskPool`] that downloads the image bytes via
//! `reqwest` (reusing the same HTTP client pattern as the sync client).
//! 3. [`poll_avatar_task`] harvests the result, decodes the bytes into a
//! Bevy [`Image`] via `image::load_from_memory`, inserts it into
//! [`Assets<Image>`], and stores the [`Handle<Image>`] in
//! [`AvatarResource`].
//! 4. `profile_plugin` reads [`AvatarResource`] when the profile modal
//! opens and renders the avatar image (or an initials fallback when
//! `AvatarResource` is `None`).
use bevy::asset::RenderAssetUsages;
use bevy::prelude::*;
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
/// Stores the loaded avatar [`Handle<Image>`], or `None` when no avatar
/// has been fetched yet (new account, no internet, or fetch in progress).
#[derive(Resource, Default)]
pub struct AvatarResource(pub Option<Handle<Image>>);
/// Fired by `sync_setup_plugin` after a successful login or register when
/// the server reports that the user has a profile picture set.
#[derive(Debug, Clone)]
pub struct AvatarFetchEvent {
/// Full HTTP(S) URL to the avatar image (base_url + avatar_url path).
pub url: String,
}
impl bevy::prelude::Message for AvatarFetchEvent {}
/// In-flight avatar download task. Returns the raw image bytes on success,
/// or `None` on any network / decode error.
#[derive(Resource, Default)]
struct PendingAvatarTask(Option<Task<Option<Vec<u8>>>>);
pub struct AvatarPlugin;
impl Plugin for AvatarPlugin {
fn build(&self, app: &mut App) {
app.add_message::<AvatarFetchEvent>()
.init_resource::<AvatarResource>()
.init_resource::<PendingAvatarTask>()
.add_systems(Update, (handle_avatar_fetch, poll_avatar_task));
}
}
fn handle_avatar_fetch(
mut events: MessageReader<AvatarFetchEvent>,
mut pending: ResMut<PendingAvatarTask>,
) {
for ev in events.read() {
// Cancel any in-flight task and restart with the new URL.
let url = ev.url.clone();
pending.0 = Some(AsyncComputeTaskPool::get().spawn(async move {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.ok()?
.block_on(async move {
let client = reqwest::Client::new();
let bytes = client
.get(&url)
.send()
.await
.ok()?
.bytes()
.await
.ok()?;
Some(bytes.to_vec())
})
}));
}
}
fn poll_avatar_task(
mut pending: ResMut<PendingAvatarTask>,
mut avatar: ResMut<AvatarResource>,
mut images: ResMut<Assets<Image>>,
) {
let Some(task) = pending.0.as_mut() else {
return;
};
let Some(result) = future::block_on(future::poll_once(task)) else {
return;
};
pending.0 = None;
let Some(bytes) = result else { return };
match image::load_from_memory(&bytes) {
Ok(dyn_img) => {
let bevy_img = Image::from_dynamic(dyn_img, true, RenderAssetUsages::RENDER_WORLD);
let handle = images.add(bevy_img);
avatar.0 = Some(handle);
}
Err(e) => {
warn!("avatar_plugin: failed to decode avatar image: {e}");
}
}
}
+2
View File
@@ -7,6 +7,7 @@ pub mod card_animation;
pub mod achievement_plugin;
pub mod analytics_plugin;
pub mod animation_plugin;
pub mod avatar_plugin;
pub mod auto_complete_plugin;
pub mod audio_plugin;
pub mod card_plugin;
@@ -123,6 +124,7 @@ pub use leaderboard_plugin::{LeaderboardPlugin, LeaderboardResource, Leaderboard
pub use input_plugin::InputPlugin;
pub use onboarding_plugin::{OnboardingPlugin, OnboardingScreen};
pub use pause_plugin::{ForfeitConfirmScreen, PausePlugin, PauseScreen, PausedResource};
pub use avatar_plugin::{AvatarFetchEvent, AvatarPlugin, AvatarResource};
pub use profile_plugin::{ProfilePlugin, ProfileScreen};
pub use radial_menu::{
legal_destinations_for_card, radial_anchor_for_index, radial_hovered_index, RadialIcon,
+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 {
+27 -7
View File
@@ -46,6 +46,7 @@ use solitaire_data::{
SyncError,
};
use crate::avatar_plugin::AvatarFetchEvent;
use crate::events::{
DeleteAccountRequestEvent, InfoToastEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent,
SyncLogoutRequestEvent,
@@ -135,9 +136,12 @@ impl SyncFocusedField {
/// In-flight login/register task. `url` and `username` are preserved so the
/// poll system can update settings and provider on success without re-reading
/// the (already-despawned or cleared) form fields.
/// Return type of the async auth + profile-fetch task.
type AuthTaskResult = Result<(String, String, Option<String>), SyncError>;
#[derive(Resource, Default)]
struct PendingAuthTask {
task: Option<Task<Result<(String, String), SyncError>>>,
task: Option<Task<AuthTaskResult>>,
url: String,
username: String,
}
@@ -366,11 +370,18 @@ fn handle_auth_button(
.build()
.map_err(|e| SyncError::Network(format!("tokio rt: {e}")))?
.block_on(async {
if is_register {
client.register(&pw).await
let (access_token, refresh_token) = if is_register {
client.register(&pw).await?
} else {
client.login(&pw).await
}
client.login(&pw).await?
};
// Fetch avatar URL immediately while we have the fresh token.
let avatar_url = client
.fetch_me_with_token(&access_token)
.await
.ok()
.and_then(|(_, url)| url);
Ok((access_token, refresh_token, avatar_url))
})
});
@@ -393,6 +404,7 @@ fn poll_auth_task(
mut commands: Commands,
mut manual_sync: MessageWriter<ManualSyncRequestEvent>,
mut toast: MessageWriter<InfoToastEvent>,
mut avatar_fetch: MessageWriter<AvatarFetchEvent>,
) {
let Some(task) = pending.task.as_mut() else {
return;
@@ -407,7 +419,7 @@ fn poll_auth_task(
}
match result {
Ok((access_token, refresh_token)) => {
Ok((access_token, refresh_token, fetched_avatar_url)) => {
let url = pending.url.clone();
let username = pending.username.clone();
@@ -424,7 +436,7 @@ fn poll_auth_task(
settings.0.sync_backend = SyncBackend::SolitaireServer {
url: url.clone(),
username: username.clone(),
avatar_url: None,
avatar_url: fetched_avatar_url.clone(),
};
if let Some(path) = &settings_path.0
&& let Err(e) = save_settings_to(path, &settings.0)
@@ -438,6 +450,14 @@ fn poll_auth_task(
// Kick off an immediate pull with the new provider.
manual_sync.write(ManualSyncRequestEvent);
// Trigger avatar download if the server reported a profile picture.
if let Some(ref rel_url) = fetched_avatar_url {
let base = pending.url.trim_end_matches('/').to_string();
avatar_fetch.write(AvatarFetchEvent {
url: format!("{base}{rel_url}"),
});
}
// Close both the setup modal and the settings panel.
for entity in &screen {
commands.entity(entity).despawn();