feat(engine): wire avatar download and display into profile modal
Build and Deploy / build-and-push (push) Successful in 4m15s
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:
Generated
+7
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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}")),
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user