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
+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();