feat(sync): account deletion flow + handle_sync_buttons refactor

Adds a two-step account-deletion UX: "Delete Account" button in the
Settings sync section (visible only when server backend is configured)
fires DeleteAccountRequestEvent → SyncSetupPlugin opens a confirmation
modal. "Delete Forever" spawns an async delete_account task; on success
SyncLogoutRequestEvent clears local credentials and resets the backend.
Errors surface via InfoToast.

Also splits handle_settings_buttons into handle_settings_buttons +
handle_sync_buttons to stay within Bevy's 16-parameter system limit.
Sync buttons (Sync Now, Connect, Disconnect, Delete Account) are now
handled in the dedicated handle_sync_buttons system.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-12 12:53:32 -07:00
parent 6ce55646d8
commit 272d31f851
3 changed files with 243 additions and 14 deletions
+6
View File
@@ -140,6 +140,12 @@ pub struct SyncConfigureRequestEvent;
#[derive(Message, Debug, Clone, Copy, Default)] #[derive(Message, Debug, Clone, Copy, Default)]
pub struct SyncLogoutRequestEvent; pub struct SyncLogoutRequestEvent;
/// Request to open the account-deletion confirmation modal. Fired by the
/// "Delete Account" button in the Settings sync section (visible only when
/// a server backend is configured). Consumed by `SyncSetupPlugin`.
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct DeleteAccountRequestEvent;
/// Request to toggle the pause overlay. Fired by the HUD "Pause" button so /// Request to toggle the pause overlay. Fired by the HUD "Pause" button so
/// the same toggle path runs whether the player presses `Esc` or clicks. /// the same toggle path runs whether the player presses `Esc` or clicks.
/// Consumed by `pause_plugin::toggle_pause`, which honours the same drag / /// Consumed by `pause_plugin::toggle_pause`, which honours the same drag /
+43 -13
View File
@@ -25,8 +25,8 @@ use solitaire_data::{
use solitaire_data::settings::SyncBackend; use solitaire_data::settings::SyncBackend;
use crate::events::{ use crate::events::{
InfoToastEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent, SyncLogoutRequestEvent, DeleteAccountRequestEvent, InfoToastEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent,
ToggleSettingsRequestEvent, SyncLogoutRequestEvent, ToggleSettingsRequestEvent,
}; };
use crate::font_plugin::FontResource; use crate::font_plugin::FontResource;
use crate::progress_plugin::ProgressResource; use crate::progress_plugin::ProgressResource;
@@ -240,6 +240,8 @@ enum SettingsButton {
ConnectSync, ConnectSync,
/// Disconnect from the sync server (shown when backend = SolitaireServer). /// Disconnect from the sync server (shown when backend = SolitaireServer).
DisconnectSync, DisconnectSync,
/// Open the account-deletion confirmation modal.
DeleteAccount,
Done, Done,
/// Select a specific card-back by index from the picker row. /// Select a specific card-back by index from the picker row.
SelectCardBack(usize), SelectCardBack(usize),
@@ -295,6 +297,7 @@ impl SettingsButton {
SettingsButton::SyncNow => 90, SettingsButton::SyncNow => 90,
SettingsButton::ConnectSync => 91, SettingsButton::ConnectSync => 91,
SettingsButton::DisconnectSync => 92, SettingsButton::DisconnectSync => 92,
SettingsButton::DeleteAccount => 93,
// Done is tagged by `attach_focusable_to_modal_buttons` and // Done is tagged by `attach_focusable_to_modal_buttons` and
// never reaches `attach_focusable_to_settings_buttons`; the // never reaches `attach_focusable_to_settings_buttons`; the
// value here is only a fallback for completeness. // value here is only a fallback for completeness.
@@ -346,6 +349,7 @@ impl Plugin for SettingsPlugin {
.add_message::<ManualSyncRequestEvent>() .add_message::<ManualSyncRequestEvent>()
.add_message::<SyncConfigureRequestEvent>() .add_message::<SyncConfigureRequestEvent>()
.add_message::<SyncLogoutRequestEvent>() .add_message::<SyncLogoutRequestEvent>()
.add_message::<DeleteAccountRequestEvent>()
.add_message::<ToggleSettingsRequestEvent>() .add_message::<ToggleSettingsRequestEvent>()
.add_message::<InfoToastEvent>() .add_message::<InfoToastEvent>()
.add_message::<bevy::input::mouse::MouseWheel>() .add_message::<bevy::input::mouse::MouseWheel>()
@@ -372,6 +376,7 @@ impl Plugin for SettingsPlugin {
( (
sync_settings_panel_visibility, sync_settings_panel_visibility,
handle_settings_buttons, handle_settings_buttons,
handle_sync_buttons,
update_sync_status_text, update_sync_status_text,
update_card_back_text, update_card_back_text,
update_background_text, update_background_text,
@@ -853,7 +858,6 @@ fn handle_settings_buttons(
mut screen: ResMut<SettingsScreen>, mut screen: ResMut<SettingsScreen>,
path: Res<SettingsStoragePath>, path: Res<SettingsStoragePath>,
mut changed: MessageWriter<SettingsChangedEvent>, mut changed: MessageWriter<SettingsChangedEvent>,
mut manual_sync: MessageWriter<ManualSyncRequestEvent>,
mut sfx_text: Query<&mut Text, (With<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>, Without<HighContrastText>, Without<ReduceMotionText>)>, mut sfx_text: Query<&mut Text, (With<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>, Without<HighContrastText>, Without<ReduceMotionText>)>,
mut music_text: Query<&mut Text, (With<MusicVolumeText>, Without<SfxVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>, Without<HighContrastText>, Without<ReduceMotionText>)>, mut music_text: Query<&mut Text, (With<MusicVolumeText>, Without<SfxVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>, Without<HighContrastText>, Without<ReduceMotionText>)>,
mut draw_text: Query<&mut Text, (With<DrawModeText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>, Without<HighContrastText>, Without<ReduceMotionText>)>, mut draw_text: Query<&mut Text, (With<DrawModeText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>, Without<HighContrastText>, Without<ReduceMotionText>)>,
@@ -862,8 +866,6 @@ fn handle_settings_buttons(
mut color_blind_text: Query<&mut Text, (With<ColorBlindText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<HighContrastText>, Without<ReduceMotionText>)>, mut color_blind_text: Query<&mut Text, (With<ColorBlindText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<HighContrastText>, Without<ReduceMotionText>)>,
mut high_contrast_text: Query<&mut Text, (With<HighContrastText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>, Without<ReduceMotionText>)>, mut high_contrast_text: Query<&mut Text, (With<HighContrastText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>, Without<ReduceMotionText>)>,
mut reduce_motion_text: Query<&mut Text, (With<ReduceMotionText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>, Without<HighContrastText>)>, mut reduce_motion_text: Query<&mut Text, (With<ReduceMotionText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>, Without<HighContrastText>)>,
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
mut logout_sync: MessageWriter<SyncLogoutRequestEvent>,
) { ) {
for (interaction, button) in &interaction_query { for (interaction, button) in &interaction_query {
if *interaction != Interaction::Pressed { if *interaction != Interaction::Pressed {
@@ -1068,14 +1070,11 @@ fn handle_settings_buttons(
changed.write(SettingsChangedEvent(settings.0.clone())); changed.write(SettingsChangedEvent(settings.0.clone()));
} }
} }
SettingsButton::SyncNow => { SettingsButton::SyncNow
manual_sync.write(ManualSyncRequestEvent); | SettingsButton::ConnectSync
} | SettingsButton::DisconnectSync
SettingsButton::ConnectSync => { | SettingsButton::DeleteAccount => {
configure_sync.write(SyncConfigureRequestEvent); // Handled by `handle_sync_buttons`.
}
SettingsButton::DisconnectSync => {
logout_sync.write(SyncLogoutRequestEvent);
} }
SettingsButton::Done => { SettingsButton::Done => {
screen.0 = false; screen.0 = false;
@@ -1084,6 +1083,30 @@ fn handle_settings_buttons(
} }
} }
/// Handles sync-related settings buttons: Sync Now, Connect, Disconnect,
/// and Delete Account. Split from `handle_settings_buttons` to stay within
/// Bevy's 16-parameter system limit.
fn handle_sync_buttons(
interaction_query: Query<(&Interaction, &SettingsButton), Changed<Interaction>>,
mut manual_sync: MessageWriter<ManualSyncRequestEvent>,
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
mut logout_sync: MessageWriter<SyncLogoutRequestEvent>,
mut delete_account: MessageWriter<DeleteAccountRequestEvent>,
) {
for (interaction, button) in &interaction_query {
if *interaction != Interaction::Pressed {
continue;
}
match button {
SettingsButton::SyncNow => { manual_sync.write(ManualSyncRequestEvent); }
SettingsButton::ConnectSync => { configure_sync.write(SyncConfigureRequestEvent); }
SettingsButton::DisconnectSync => { logout_sync.write(SyncLogoutRequestEvent); }
SettingsButton::DeleteAccount => { delete_account.write(DeleteAccountRequestEvent); }
_ => {}
}
}
}
fn draw_mode_label(mode: &DrawMode) -> String { fn draw_mode_label(mode: &DrawMode) -> String {
match mode { match mode {
DrawMode::DrawOne => "Draw 1".into(), DrawMode::DrawOne => "Draw 1".into(),
@@ -2335,6 +2358,13 @@ fn sync_row(
SettingsButton::DisconnectSync, SettingsButton::DisconnectSync,
"Disconnect", "Disconnect",
"Unlink this device from the sync server.".to_string(), "Unlink this device from the sync server.".to_string(),
button_font.clone(),
);
small_button(
row,
SettingsButton::DeleteAccount,
"Delete Account",
"Permanently delete your account and all server data. Cannot be undone.".to_string(),
button_font, button_font,
); );
} }
+194 -1
View File
@@ -18,6 +18,19 @@
//! //!
//! `SyncLogoutRequestEvent` → `handle_logout` clears tokens, resets //! `SyncLogoutRequestEvent` → `handle_logout` clears tokens, resets
//! `SyncBackend::Local`, swaps provider, closes settings, shows toast. //! `SyncBackend::Local`, swaps provider, closes settings, shows toast.
//!
//! # Flow (delete account)
//!
//! 1. Player clicks "Delete Account" in Settings.
//! 2. `DeleteAccountRequestEvent` → `open_delete_confirm_modal` spawns a
//! two-button confirmation modal.
//! 3. "Cancel" → despawn modal.
//! 4. "Delete Forever" → `handle_delete_confirm` → async task on
//! `AsyncComputeTaskPool` calling `SyncProvider::delete_account`.
//! 5. `poll_delete_task` harvests the result:
//! - **Ok**: fire `SyncLogoutRequestEvent` (clears tokens + resets backend)
//! + toast.
//! - **Err**: display error in a toast; modal is already closed.
use std::sync::Arc; use std::sync::Arc;
@@ -34,7 +47,8 @@ use solitaire_data::{
}; };
use crate::events::{ use crate::events::{
InfoToastEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent, SyncLogoutRequestEvent, DeleteAccountRequestEvent, InfoToastEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent,
SyncLogoutRequestEvent,
}; };
use crate::font_plugin::FontResource; use crate::font_plugin::FontResource;
use crate::settings_plugin::{SettingsResource, SettingsScreen, SettingsStoragePath}; use crate::settings_plugin::{SettingsResource, SettingsScreen, SettingsStoragePath};
@@ -128,6 +142,22 @@ struct PendingAuthTask {
username: String, username: String,
} }
/// Marker on the account-deletion confirmation modal root.
#[derive(Component, Debug)]
struct DeleteConfirmScreen;
/// Marks the "Delete Forever" confirmation button.
#[derive(Component, Debug)]
struct DeleteConfirmButton;
/// Marks the cancel button inside the delete-confirm modal.
#[derive(Component, Debug)]
struct DeleteCancelButton;
/// In-flight account-deletion task.
#[derive(Resource, Default)]
struct PendingDeleteTask(Option<Task<Result<(), SyncError>>>);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Plugin // Plugin
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -139,8 +169,10 @@ impl Plugin for SyncSetupPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.init_resource::<SyncFocusedField>() app.init_resource::<SyncFocusedField>()
.init_resource::<PendingAuthTask>() .init_resource::<PendingAuthTask>()
.init_resource::<PendingDeleteTask>()
.add_message::<SyncConfigureRequestEvent>() .add_message::<SyncConfigureRequestEvent>()
.add_message::<SyncLogoutRequestEvent>() .add_message::<SyncLogoutRequestEvent>()
.add_message::<DeleteAccountRequestEvent>()
.add_message::<ManualSyncRequestEvent>() .add_message::<ManualSyncRequestEvent>()
.add_message::<InfoToastEvent>() .add_message::<InfoToastEvent>()
.add_systems( .add_systems(
@@ -153,6 +185,10 @@ impl Plugin for SyncSetupPlugin {
poll_auth_task, poll_auth_task,
handle_cancel, handle_cancel,
handle_logout, handle_logout,
open_delete_confirm_modal,
handle_delete_cancel,
handle_delete_confirm,
poll_delete_task,
) )
.chain(), .chain(),
); );
@@ -480,6 +516,94 @@ fn handle_logout(
toast.write(InfoToastEvent("Disconnected from sync server".to_string())); toast.write(InfoToastEvent("Disconnected from sync server".to_string()));
} }
/// Opens the account-deletion confirmation modal when `DeleteAccountRequestEvent` fires.
fn open_delete_confirm_modal(
mut events: MessageReader<DeleteAccountRequestEvent>,
existing: Query<(), With<DeleteConfirmScreen>>,
mut commands: Commands,
font_res: Option<Res<FontResource>>,
) {
if events.is_empty() {
return;
}
events.clear();
if !existing.is_empty() {
return;
}
spawn_delete_confirm_modal(&mut commands, font_res.as_deref());
}
/// Despawns the delete-confirm modal on the cancel button or Escape.
fn handle_delete_cancel(
cancel_q: Query<&Interaction, (Changed<Interaction>, With<DeleteCancelButton>)>,
keys: Res<ButtonInput<KeyCode>>,
screen: Query<Entity, With<DeleteConfirmScreen>>,
mut commands: Commands,
) {
let cancelled = cancel_q.iter().any(|i| *i == Interaction::Pressed)
|| keys.just_pressed(KeyCode::Escape);
if !cancelled || screen.is_empty() {
return;
}
for entity in &screen {
commands.entity(entity).despawn();
}
}
/// Spawns the async delete-account task when "Delete Forever" is clicked.
fn handle_delete_confirm(
confirm_q: Query<&Interaction, (Changed<Interaction>, With<DeleteConfirmButton>)>,
provider: Res<SyncProviderResource>,
mut pending: ResMut<PendingDeleteTask>,
screen: Query<Entity, With<DeleteConfirmScreen>>,
mut commands: Commands,
) {
if !confirm_q.iter().any(|i| *i == Interaction::Pressed) || pending.0.is_some() {
return;
}
// Despawn the confirmation modal immediately so the player can't double-click.
for entity in &screen {
commands.entity(entity).despawn();
}
let provider = provider.0.clone();
pending.0 = Some(AsyncComputeTaskPool::get().spawn(async move {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| SyncError::Network(format!("tokio rt: {e}")))?
.block_on(provider.delete_account())
}));
}
/// Polls the in-flight delete-account task. On success fires `SyncLogoutRequestEvent`.
fn poll_delete_task(
mut pending: ResMut<PendingDeleteTask>,
mut logout: MessageWriter<SyncLogoutRequestEvent>,
mut toast: MessageWriter<InfoToastEvent>,
) {
let Some(task) = pending.0.as_mut() else {
return;
};
let Some(result) = future::block_on(future::poll_once(task)) else {
return;
};
pending.0 = None;
match result {
Ok(()) => {
logout.write(SyncLogoutRequestEvent);
toast.write(InfoToastEvent("Account deleted".to_string()));
}
Err(e) => {
let msg = match e {
SyncError::Auth(_) => "Not authorised — try reconnecting first".to_string(),
SyncError::Network(m) => format!("Network error: {m}"),
other => format!("Delete failed: {other}"),
};
toast.write(InfoToastEvent(msg));
}
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// UI construction // UI construction
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -666,6 +790,75 @@ fn make_font(font_res: Option<&FontResource>, size: f32) -> TextFont {
} }
} }
fn spawn_delete_confirm_modal(commands: &mut Commands, font_res: Option<&FontResource>) {
spawn_modal(commands, DeleteConfirmScreen, Z_MODAL_PANEL + 2, |card| {
// Header.
card.spawn(Node {
padding: UiRect::new(VAL_SPACE_4, VAL_SPACE_4, VAL_SPACE_3, VAL_SPACE_2),
..default()
})
.with_children(|h| {
h.spawn((
Text::new("Delete Account"),
make_font(font_res, TYPE_BODY_LG),
TextColor(STATE_DANGER),
));
});
// Body.
card.spawn(Node {
flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_2,
padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_2),
..default()
})
.with_children(|body| {
body.spawn((
Text::new(
"This permanently deletes your account and all server data.\n\
Local progress is kept. This cannot be undone.",
),
make_font(font_res, TYPE_BODY),
TextColor(TEXT_SECONDARY),
));
});
// Actions.
card.spawn(Node {
flex_direction: FlexDirection::Row,
justify_content: JustifyContent::FlexEnd,
column_gap: VAL_SPACE_2,
padding: UiRect::new(VAL_SPACE_4, VAL_SPACE_4, VAL_SPACE_2, VAL_SPACE_3),
..default()
})
.with_children(|actions| {
spawn_action_button(actions, DeleteCancelButton, "Cancel", false, font_res);
// "Delete Forever" button — danger styling (STATE_DANGER background).
actions
.spawn((
DeleteConfirmButton,
Button,
Node {
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2),
justify_content: JustifyContent::Center,
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
..default()
},
BackgroundColor(STATE_DANGER),
BorderColor::all(STATE_DANGER),
))
.with_children(|b| {
b.spawn((
Text::new("Delete Forever"),
make_font(font_res, TYPE_BODY),
TextColor(TEXT_PRIMARY),
));
});
});
});
}
/// Returns the display string for a field — password fields show bullets. /// Returns the display string for a field — password fields show bullets.
fn display_text(raw: &str, kind: SyncFieldKind) -> String { fn display_text(raw: &str, kind: SyncFieldKind) -> String {
if kind == SyncFieldKind::Password { if kind == SyncFieldKind::Password {