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:
@@ -140,6 +140,12 @@ pub struct SyncConfigureRequestEvent;
|
||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||
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
|
||||
/// the same toggle path runs whether the player presses `Esc` or clicks.
|
||||
/// Consumed by `pause_plugin::toggle_pause`, which honours the same drag /
|
||||
|
||||
@@ -25,8 +25,8 @@ use solitaire_data::{
|
||||
use solitaire_data::settings::SyncBackend;
|
||||
|
||||
use crate::events::{
|
||||
InfoToastEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent, SyncLogoutRequestEvent,
|
||||
ToggleSettingsRequestEvent,
|
||||
DeleteAccountRequestEvent, InfoToastEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent,
|
||||
SyncLogoutRequestEvent, ToggleSettingsRequestEvent,
|
||||
};
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
@@ -240,6 +240,8 @@ enum SettingsButton {
|
||||
ConnectSync,
|
||||
/// Disconnect from the sync server (shown when backend = SolitaireServer).
|
||||
DisconnectSync,
|
||||
/// Open the account-deletion confirmation modal.
|
||||
DeleteAccount,
|
||||
Done,
|
||||
/// Select a specific card-back by index from the picker row.
|
||||
SelectCardBack(usize),
|
||||
@@ -295,6 +297,7 @@ impl SettingsButton {
|
||||
SettingsButton::SyncNow => 90,
|
||||
SettingsButton::ConnectSync => 91,
|
||||
SettingsButton::DisconnectSync => 92,
|
||||
SettingsButton::DeleteAccount => 93,
|
||||
// Done is tagged by `attach_focusable_to_modal_buttons` and
|
||||
// never reaches `attach_focusable_to_settings_buttons`; the
|
||||
// value here is only a fallback for completeness.
|
||||
@@ -346,6 +349,7 @@ impl Plugin for SettingsPlugin {
|
||||
.add_message::<ManualSyncRequestEvent>()
|
||||
.add_message::<SyncConfigureRequestEvent>()
|
||||
.add_message::<SyncLogoutRequestEvent>()
|
||||
.add_message::<DeleteAccountRequestEvent>()
|
||||
.add_message::<ToggleSettingsRequestEvent>()
|
||||
.add_message::<InfoToastEvent>()
|
||||
.add_message::<bevy::input::mouse::MouseWheel>()
|
||||
@@ -372,6 +376,7 @@ impl Plugin for SettingsPlugin {
|
||||
(
|
||||
sync_settings_panel_visibility,
|
||||
handle_settings_buttons,
|
||||
handle_sync_buttons,
|
||||
update_sync_status_text,
|
||||
update_card_back_text,
|
||||
update_background_text,
|
||||
@@ -853,7 +858,6 @@ fn handle_settings_buttons(
|
||||
mut screen: ResMut<SettingsScreen>,
|
||||
path: Res<SettingsStoragePath>,
|
||||
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 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>)>,
|
||||
@@ -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 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 configure_sync: MessageWriter<SyncConfigureRequestEvent>,
|
||||
mut logout_sync: MessageWriter<SyncLogoutRequestEvent>,
|
||||
) {
|
||||
for (interaction, button) in &interaction_query {
|
||||
if *interaction != Interaction::Pressed {
|
||||
@@ -1068,14 +1070,11 @@ fn handle_settings_buttons(
|
||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||
}
|
||||
}
|
||||
SettingsButton::SyncNow => {
|
||||
manual_sync.write(ManualSyncRequestEvent);
|
||||
}
|
||||
SettingsButton::ConnectSync => {
|
||||
configure_sync.write(SyncConfigureRequestEvent);
|
||||
}
|
||||
SettingsButton::DisconnectSync => {
|
||||
logout_sync.write(SyncLogoutRequestEvent);
|
||||
SettingsButton::SyncNow
|
||||
| SettingsButton::ConnectSync
|
||||
| SettingsButton::DisconnectSync
|
||||
| SettingsButton::DeleteAccount => {
|
||||
// Handled by `handle_sync_buttons`.
|
||||
}
|
||||
SettingsButton::Done => {
|
||||
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 {
|
||||
match mode {
|
||||
DrawMode::DrawOne => "Draw 1".into(),
|
||||
@@ -2335,6 +2358,13 @@ fn sync_row(
|
||||
SettingsButton::DisconnectSync,
|
||||
"Disconnect",
|
||||
"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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,19 @@
|
||||
//!
|
||||
//! `SyncLogoutRequestEvent` → `handle_logout` clears tokens, resets
|
||||
//! `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;
|
||||
|
||||
@@ -34,7 +47,8 @@ use solitaire_data::{
|
||||
};
|
||||
|
||||
use crate::events::{
|
||||
InfoToastEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent, SyncLogoutRequestEvent,
|
||||
DeleteAccountRequestEvent, InfoToastEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent,
|
||||
SyncLogoutRequestEvent,
|
||||
};
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::settings_plugin::{SettingsResource, SettingsScreen, SettingsStoragePath};
|
||||
@@ -128,6 +142,22 @@ struct PendingAuthTask {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -139,8 +169,10 @@ impl Plugin for SyncSetupPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<SyncFocusedField>()
|
||||
.init_resource::<PendingAuthTask>()
|
||||
.init_resource::<PendingDeleteTask>()
|
||||
.add_message::<SyncConfigureRequestEvent>()
|
||||
.add_message::<SyncLogoutRequestEvent>()
|
||||
.add_message::<DeleteAccountRequestEvent>()
|
||||
.add_message::<ManualSyncRequestEvent>()
|
||||
.add_message::<InfoToastEvent>()
|
||||
.add_systems(
|
||||
@@ -153,6 +185,10 @@ impl Plugin for SyncSetupPlugin {
|
||||
poll_auth_task,
|
||||
handle_cancel,
|
||||
handle_logout,
|
||||
open_delete_confirm_modal,
|
||||
handle_delete_cancel,
|
||||
handle_delete_confirm,
|
||||
poll_delete_task,
|
||||
)
|
||||
.chain(),
|
||||
);
|
||||
@@ -480,6 +516,94 @@ fn handle_logout(
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -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.
|
||||
fn display_text(raw: &str, kind: SyncFieldKind) -> String {
|
||||
if kind == SyncFieldKind::Password {
|
||||
|
||||
Reference in New Issue
Block a user