diff --git a/solitaire_engine/src/events.rs b/solitaire_engine/src/events.rs index 6a435b7..58f587f 100644 --- a/solitaire_engine/src/events.rs +++ b/solitaire_engine/src/events.rs @@ -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 / diff --git a/solitaire_engine/src/settings_plugin.rs b/solitaire_engine/src/settings_plugin.rs index 92ccbb8..59ded09 100644 --- a/solitaire_engine/src/settings_plugin.rs +++ b/solitaire_engine/src/settings_plugin.rs @@ -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::() .add_message::() .add_message::() + .add_message::() .add_message::() .add_message::() .add_message::() @@ -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, path: Res, mut changed: MessageWriter, - mut manual_sync: MessageWriter, mut sfx_text: Query<&mut Text, (With, Without, Without, Without, Without, Without, Without, Without)>, mut music_text: Query<&mut Text, (With, Without, Without, Without, Without, Without, Without, Without)>, mut draw_text: Query<&mut Text, (With, Without, Without, Without, Without, Without, Without, Without)>, @@ -862,8 +866,6 @@ fn handle_settings_buttons( mut color_blind_text: Query<&mut Text, (With, Without, Without, Without, Without, Without, Without, Without)>, mut high_contrast_text: Query<&mut Text, (With, Without, Without, Without, Without, Without, Without, Without)>, mut reduce_motion_text: Query<&mut Text, (With, Without, Without, Without, Without, Without, Without, Without)>, - mut configure_sync: MessageWriter, - mut logout_sync: MessageWriter, ) { 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>, + mut manual_sync: MessageWriter, + mut configure_sync: MessageWriter, + mut logout_sync: MessageWriter, + mut delete_account: MessageWriter, +) { + 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, ); } diff --git a/solitaire_engine/src/sync_setup_plugin.rs b/solitaire_engine/src/sync_setup_plugin.rs index 2247933..ca65471 100644 --- a/solitaire_engine/src/sync_setup_plugin.rs +++ b/solitaire_engine/src/sync_setup_plugin.rs @@ -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>>); + // --------------------------------------------------------------------------- // Plugin // --------------------------------------------------------------------------- @@ -139,8 +169,10 @@ impl Plugin for SyncSetupPlugin { fn build(&self, app: &mut App) { app.init_resource::() .init_resource::() + .init_resource::() .add_message::() .add_message::() + .add_message::() .add_message::() .add_message::() .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, + existing: Query<(), With>, + mut commands: Commands, + font_res: Option>, +) { + 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, With)>, + keys: Res>, + screen: Query>, + 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, With)>, + provider: Res, + mut pending: ResMut, + screen: Query>, + 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, + mut logout: MessageWriter, + mut toast: MessageWriter, +) { + 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 {