From 677999a51e4e602ee7ff9a4c376fb20bea23bbb7 Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 14 May 2026 17:27:25 -0700 Subject: [PATCH] feat(engine): wire avatar download and display into profile modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 - 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 --- Cargo.lock | 7 ++ Cargo.toml | 3 + solitaire_app/src/lib.rs | 3 +- solitaire_data/src/sync_client.rs | 15 +++ solitaire_engine/Cargo.toml | 2 + solitaire_engine/src/avatar_plugin.rs | 108 ++++++++++++++++++++++ solitaire_engine/src/lib.rs | 2 + solitaire_engine/src/profile_plugin.rs | 65 ++++++++++++- solitaire_engine/src/sync_setup_plugin.rs | 34 +++++-- 9 files changed, 226 insertions(+), 13 deletions(-) create mode 100644 solitaire_engine/src/avatar_plugin.rs diff --git a/Cargo.lock b/Cargo.lock index e602e00..b9d76c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index ea2061c..d8e9253 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 diff --git a/solitaire_app/src/lib.rs b/solitaire_app/src/lib.rs index 8fdc0b1..46d7178 100644 --- a/solitaire_app/src/lib.rs +++ b/solitaire_app/src/lib.rs @@ -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()) diff --git a/solitaire_data/src/sync_client.rs b/solitaire_data/src/sync_client.rs index a42337f..408ec98 100644 --- a/solitaire_data/src/sync_client.rs +++ b/solitaire_data/src/sync_client.rs @@ -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), 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), SyncError> { let status = resp.status(); if !status.is_success() { diff --git a/solitaire_engine/Cargo.toml b/solitaire_engine/Cargo.toml index 43616e6..82c11c2 100644 --- a/solitaire_engine/Cargo.toml +++ b/solitaire_engine/Cargo.toml @@ -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 } diff --git a/solitaire_engine/src/avatar_plugin.rs b/solitaire_engine/src/avatar_plugin.rs new file mode 100644 index 0000000..46077e2 --- /dev/null +++ b/solitaire_engine/src/avatar_plugin.rs @@ -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`], and stores the [`Handle`] 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`], 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>); + +/// 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>>>); + +pub struct AvatarPlugin; + +impl Plugin for AvatarPlugin { + fn build(&self, app: &mut App) { + app.add_message::() + .init_resource::() + .init_resource::() + .add_systems(Update, (handle_avatar_fetch, poll_avatar_task)); + } +} + +fn handle_avatar_fetch( + mut events: MessageReader, + mut pending: ResMut, +) { + 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, + mut avatar: ResMut, + mut images: ResMut>, +) { + 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}"); + } + } +} diff --git a/solitaire_engine/src/lib.rs b/solitaire_engine/src/lib.rs index 235e1cd..79a7a93 100644 --- a/solitaire_engine/src/lib.rs +++ b/solitaire_engine/src/lib.rs @@ -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, diff --git a/solitaire_engine/src/profile_plugin.rs b/solitaire_engine/src/profile_plugin.rs index e6c81d0..73e73ac 100644 --- a/solitaire_engine/src/profile_plugin.rs +++ b/solitaire_engine/src/profile_plugin.rs @@ -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>, stats: Option>, font_res: Option>, + avatar: Option>, screens: Query>, ) { 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 { diff --git a/solitaire_engine/src/sync_setup_plugin.rs b/solitaire_engine/src/sync_setup_plugin.rs index cb6a07d..b4ed529 100644 --- a/solitaire_engine/src/sync_setup_plugin.rs +++ b/solitaire_engine/src/sync_setup_plugin.rs @@ -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), SyncError>; + #[derive(Resource, Default)] struct PendingAuthTask { - task: Option>>, + task: Option>, 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, mut toast: MessageWriter, + mut avatar_fetch: MessageWriter, ) { 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();