diff --git a/solitaire_app/src/lib.rs b/solitaire_app/src/lib.rs index 02e7855..196a3fb 100644 --- a/solitaire_app/src/lib.rs +++ b/solitaire_app/src/lib.rs @@ -32,7 +32,7 @@ use solitaire_engine::{ OnboardingPlugin, PausePlugin, PlayBySeedPlugin, ProfilePlugin, ProgressPlugin, RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin, SafeAreaInsetsPlugin, SelectionPlugin, SettingsPlugin, - SplashPlugin, StatsPlugin, SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin, + SplashPlugin, StatsPlugin, SyncPlugin, SyncSetupPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin, }; @@ -193,6 +193,7 @@ pub fn run() { .add_plugins(AudioPlugin) .add_plugins(OnboardingPlugin) .add_plugins(SyncPlugin::new(sync_provider)) + .add_plugins(SyncSetupPlugin) .add_plugins(LeaderboardPlugin) .add_plugins(WinSummaryPlugin) .add_plugins(UiModalPlugin) diff --git a/solitaire_data/src/sync_client.rs b/solitaire_data/src/sync_client.rs index 2d54180..f365eb5 100644 --- a/solitaire_data/src/sync_client.rs +++ b/solitaire_data/src/sync_client.rs @@ -83,6 +83,83 @@ impl SolitaireServerClient { } } + /// Authenticate with a username + password and return `(access_token, refresh_token)`. + /// + /// On success call [`crate::auth_tokens::store_tokens`] with the returned pair. + /// The client's `username` field is used as the credential — the caller must + /// construct the client with the correct username before calling this. + pub async fn login(&self, password: &str) -> Result<(String, String), SyncError> { + let resp = self + .client + .post(format!("{}/api/auth/login", self.base_url)) + .json(&serde_json::json!({ + "username": self.username, + "password": password, + })) + .send() + .await + .map_err(|e| SyncError::Network(e.to_string()))?; + + Self::extract_auth_tokens(resp).await + } + + /// Register a new account with a username + password and return `(access_token, refresh_token)`. + /// + /// On success call [`crate::auth_tokens::store_tokens`] with the returned pair. + pub async fn register(&self, password: &str) -> Result<(String, String), SyncError> { + let resp = self + .client + .post(format!("{}/api/auth/register", self.base_url)) + .json(&serde_json::json!({ + "username": self.username, + "password": password, + })) + .send() + .await + .map_err(|e| SyncError::Network(e.to_string()))?; + + Self::extract_auth_tokens(resp).await + } + + /// Parse `{ "access_token": "...", "refresh_token": "..." }` from an auth response. + async fn extract_auth_tokens(resp: reqwest::Response) -> Result<(String, String), SyncError> { + let status = resp.status(); + if !status.is_success() { + let body: serde_json::Value = resp + .json() + .await + .unwrap_or(serde_json::json!({})); + let msg = body["error"] + .as_str() + .or_else(|| body["message"].as_str()) + .unwrap_or("authentication failed"); + return Err(if status == reqwest::StatusCode::CONFLICT { + SyncError::Auth("username already taken".into()) + } else if status == reqwest::StatusCode::UNAUTHORIZED + || status == reqwest::StatusCode::FORBIDDEN + { + SyncError::Auth("invalid credentials".into()) + } else if status == reqwest::StatusCode::BAD_REQUEST { + SyncError::Auth(msg.to_string()) + } else { + SyncError::Network(format!("server returned {status}")) + }); + } + let body: serde_json::Value = resp + .json() + .await + .map_err(|e| SyncError::Serialization(e.to_string()))?; + let access = body["access_token"] + .as_str() + .ok_or_else(|| SyncError::Serialization("missing access_token".into()))? + .to_string(); + let refresh = body["refresh_token"] + .as_str() + .ok_or_else(|| SyncError::Serialization("missing refresh_token".into()))? + .to_string(); + Ok((access, refresh)) + } + /// Attempt to refresh the access token using the stored refresh token. /// /// On success the new access token is persisted to the OS keychain, diff --git a/solitaire_engine/src/events.rs b/solitaire_engine/src/events.rs index 4fd2417..67b19d2 100644 --- a/solitaire_engine/src/events.rs +++ b/solitaire_engine/src/events.rs @@ -129,6 +129,23 @@ pub struct AchievementUnlockedEvent(pub AchievementRecord); #[derive(Message, Debug, Clone, Copy, Default)] pub struct ManualSyncRequestEvent; +/// Request to open the sync-server setup modal (Connect flow). +/// Fired by the "Connect" button in the Settings sync section. +#[derive(Message, Debug, Clone, Copy, Default)] +pub struct SyncConfigureRequestEvent; + +/// Result of an async login or register attempt. `Ok(username)` on success; +/// `Err(human-readable message)` on failure. Consumed by `SyncSetupPlugin` +/// to update the in-world provider and surface errors in the modal. +#[derive(Message, Debug, Clone)] +pub struct SyncAuthResultEvent(pub Result); + +/// Request to disconnect from the current sync backend, clear stored +/// credentials, and reset to `SyncBackend::Local`. Fired by the "Disconnect" +/// button in the Settings sync section. +#[derive(Message, Debug, Clone, Copy, Default)] +pub struct SyncLogoutRequestEvent; + /// 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/lib.rs b/solitaire_engine/src/lib.rs index c762bf5..073f1c8 100644 --- a/solitaire_engine/src/lib.rs +++ b/solitaire_engine/src/lib.rs @@ -40,6 +40,7 @@ pub mod selection_plugin; pub mod splash_plugin; pub mod stats_plugin; pub mod sync_plugin; +pub mod sync_setup_plugin; pub mod table_plugin; pub mod theme; pub mod time_attack_plugin; @@ -150,6 +151,7 @@ pub use stats_plugin::{ StatsScreen, StatsUpdate, WatchReplayButton, }; pub use sync_plugin::{SyncPlugin, SyncProviderResource}; +pub use sync_setup_plugin::SyncSetupPlugin; pub use ui_focus::{Disabled, FocusGroup, Focusable, FocusedButton, UiFocusPlugin}; pub use ui_modal::{ spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button, diff --git a/solitaire_engine/src/settings_plugin.rs b/solitaire_engine/src/settings_plugin.rs index 97a22b1..92ccbb8 100644 --- a/solitaire_engine/src/settings_plugin.rs +++ b/solitaire_engine/src/settings_plugin.rs @@ -22,7 +22,12 @@ use solitaire_data::{ TOOLTIP_DELAY_STEP_SECS, }; -use crate::events::{InfoToastEvent, ManualSyncRequestEvent, ToggleSettingsRequestEvent}; +use solitaire_data::settings::SyncBackend; + +use crate::events::{ + InfoToastEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent, SyncLogoutRequestEvent, + ToggleSettingsRequestEvent, +}; use crate::font_plugin::FontResource; use crate::progress_plugin::ProgressResource; use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource}; @@ -231,6 +236,10 @@ enum SettingsButton { /// player's last window size always wins. ToggleSmartDefaultSize, SyncNow, + /// Open the sync-server Connect modal (shown when backend = Local). + ConnectSync, + /// Disconnect from the sync server (shown when backend = SolitaireServer). + DisconnectSync, Done, /// Select a specific card-back by index from the picker row. SelectCardBack(usize), @@ -284,6 +293,8 @@ impl SettingsButton { SettingsButton::SelectTheme(_) => 85, // Sync section SettingsButton::SyncNow => 90, + SettingsButton::ConnectSync => 91, + SettingsButton::DisconnectSync => 92, // 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. @@ -333,6 +344,8 @@ impl Plugin for SettingsPlugin { .init_resource::() .add_message::() .add_message::() + .add_message::() + .add_message::() .add_message::() .add_message::() .add_message::() @@ -849,6 +862,8 @@ 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 { @@ -1056,6 +1071,12 @@ fn handle_settings_buttons( SettingsButton::SyncNow => { manual_sync.write(ManualSyncRequestEvent); } + SettingsButton::ConnectSync => { + configure_sync.write(SyncConfigureRequestEvent); + } + SettingsButton::DisconnectSync => { + logout_sync.write(SyncLogoutRequestEvent); + } SettingsButton::Done => { screen.0 = false; } @@ -1596,7 +1617,7 @@ fn spawn_settings_panel( // --- Sync --- section_label(body, "Sync", font_res); - sync_row(body, sync_status, font_res); + sync_row(body, sync_status, &settings.sync_backend, font_res); }); // Done is the only action — primary so the player always knows @@ -2208,8 +2229,14 @@ fn spawn_thumbnail_placeholder(parent: &mut ChildSpawnerCommands) { )); } -/// Status text + manual "Sync Now" button. -fn sync_row(parent: &mut ChildSpawnerCommands, status_text: &str, font_res: Option<&FontResource>) { +/// Sync section row — shows different controls depending on whether a server +/// backend is configured. +fn sync_row( + parent: &mut ChildSpawnerCommands, + status_text: &str, + backend: &SyncBackend, + font_res: Option<&FontResource>, +) { let status_font = TextFont { font: font_res.map(|f| f.0.clone()).unwrap_or_default(), font_size: TYPE_BODY, @@ -2220,45 +2247,98 @@ fn sync_row(parent: &mut ChildSpawnerCommands, status_text: &str, font_res: Opti font_size: TYPE_CAPTION, ..default() }; + + // Helper closure to spawn a small settings-style pill button. + let small_button = |row: &mut ChildSpawnerCommands, + marker: SettingsButton, + label: &str, + tooltip: String, + font: TextFont| { + row.spawn(( + marker, + Button, + Tooltip::new(tooltip), + 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(BG_ELEVATED_HI), + BorderColor::all(BORDER_SUBTLE), + HighContrastBorder::with_default(BORDER_SUBTLE), + )) + .with_children(|b| { + b.spawn(( + Text::new(label.to_string()), + font, + TextColor(TEXT_PRIMARY), + )); + }); + }; + parent .spawn(Node { - flex_direction: FlexDirection::Row, - align_items: AlignItems::Center, - column_gap: VAL_SPACE_3, + flex_direction: FlexDirection::Column, + row_gap: VAL_SPACE_2, ..default() }) - .with_children(|row| { - row.spawn(( - SyncStatusText, - Text::new(status_text.to_string()), - status_font, - TextColor(TEXT_SECONDARY), - )); - // ManualSyncRequestEvent is always registered, so this - // button is safe to show even when SyncPlugin is absent. - row.spawn(( - SettingsButton::SyncNow, - Button, - Tooltip::new( - "Push and pull stats now. Runs automatically on launch and exit.", - ), - 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(BG_ELEVATED_HI), - BorderColor::all(BORDER_SUBTLE), - HighContrastBorder::with_default(BORDER_SUBTLE), - )) - .with_children(|b| { - b.spawn(( - Text::new("Sync Now"), - button_font, - TextColor(TEXT_PRIMARY), + .with_children(|col| { + // Status line + inline action buttons. + col.spawn(Node { + flex_direction: FlexDirection::Row, + align_items: AlignItems::Center, + column_gap: VAL_SPACE_3, + flex_wrap: FlexWrap::Wrap, + row_gap: VAL_SPACE_2, + ..default() + }) + .with_children(|row| { + row.spawn(( + SyncStatusText, + Text::new(status_text.to_string()), + status_font, + TextColor(TEXT_SECONDARY), )); + + match backend { + SyncBackend::Local => { + small_button( + row, + SettingsButton::ConnectSync, + "Connect", + "Connect to a self-hosted Solitaire Quest sync server.".to_string(), + button_font, + ); + } + SyncBackend::SolitaireServer { username, .. } => { + // Show the logged-in username as a secondary label. + row.spawn(( + Text::new(format!("({username})")), + TextFont { + font: font_res.map(|f| f.0.clone()).unwrap_or_default(), + font_size: TYPE_CAPTION, + ..default() + }, + TextColor(TEXT_SECONDARY), + )); + small_button( + row, + SettingsButton::SyncNow, + "Sync Now", + "Push and pull stats now. Runs automatically on launch and exit.".to_string(), + button_font.clone(), + ); + small_button( + row, + SettingsButton::DisconnectSync, + "Disconnect", + "Unlink this device from the sync server.".to_string(), + button_font, + ); + } + } }); }); } @@ -2620,19 +2700,20 @@ mod tests { "expected the panel to spawn many tooltipped buttons; got {tipped_count}" ); - // Spot-check: the Sync Now button's tooltip text is the - // canonical microcopy. We find it via the `SettingsButton` - // discriminant — there is exactly one Sync Now entity per panel. - let sync_tip = app + // Spot-check: with default (Local) settings the Connect button + // spawns. We verify its tooltip carries the canonical microcopy. + let connect_tip = app .world_mut() .query::<(&SettingsButton, &Tooltip)>() .iter(app.world()) - .find_map(|(btn, tip)| matches!(btn, SettingsButton::SyncNow).then(|| tip.0.clone())) - .expect("Sync Now button should spawn with a Tooltip"); + .find_map(|(btn, tip)| { + matches!(btn, SettingsButton::ConnectSync).then(|| tip.0.clone()) + }) + .expect("Connect button should spawn with a Tooltip when backend is Local"); assert_eq!( - sync_tip.as_ref(), - "Push and pull stats now. Runs automatically on launch and exit.", - "Sync Now tooltip must use the canonical microcopy" + connect_tip.as_ref(), + "Connect to a self-hosted Solitaire Quest sync server.", + "ConnectSync tooltip must use the canonical microcopy" ); } diff --git a/solitaire_engine/src/sync_setup_plugin.rs b/solitaire_engine/src/sync_setup_plugin.rs new file mode 100644 index 0000000..2247933 --- /dev/null +++ b/solitaire_engine/src/sync_setup_plugin.rs @@ -0,0 +1,683 @@ +//! Sync-server configuration UI: login / register modal, provider hot-swap, +//! and disconnect handler. +//! +//! # Flow (connect) +//! +//! 1. Player clicks "Connect" in the Settings sync section. +//! 2. `SyncConfigureRequestEvent` → `open_sync_setup_modal` spawns the form. +//! 3. Player fills URL / Username / Password; Tab cycles fields. +//! 4. "Log In" or "Register" → `handle_auth_button` → async task on +//! `AsyncComputeTaskPool` calling `SolitaireServerClient::login` or +//! `::register`. +//! 5. `poll_auth_task` harvests the result: +//! - **Ok**: store tokens → update `SettingsResource` → swap +//! `SyncProviderResource` → fire `ManualSyncRequestEvent` → toast + close. +//! - **Err**: display error inline; form stays open. +//! +//! # Flow (disconnect) +//! +//! `SyncLogoutRequestEvent` → `handle_logout` clears tokens, resets +//! `SyncBackend::Local`, swaps provider, closes settings, shows toast. + +use std::sync::Arc; + +use bevy::input::ButtonState; +use bevy::input::keyboard::KeyboardInput; +use bevy::prelude::*; +use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task}; +use solitaire_data::{ + auth_tokens::{delete_tokens, store_tokens}, + settings::SyncBackend, + save_settings_to, + sync_client::{LocalOnlyProvider, SolitaireServerClient}, + SyncError, +}; + +use crate::events::{ + InfoToastEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent, SyncLogoutRequestEvent, +}; +use crate::font_plugin::FontResource; +use crate::settings_plugin::{SettingsResource, SettingsScreen, SettingsStoragePath}; +use crate::sync_plugin::SyncProviderResource; +use crate::ui_modal::spawn_modal; +use crate::ui_theme::{ + ACCENT_PRIMARY, BG_ELEVATED, BG_ELEVATED_HI, + BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, STATE_DANGER, TEXT_DISABLED, + TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, + VAL_SPACE_4, Z_MODAL_PANEL, +}; + +// --------------------------------------------------------------------------- +// Components +// --------------------------------------------------------------------------- + +/// Marker on the sync-setup modal scrim (despawn root). +#[derive(Component, Debug)] +pub struct SyncSetupScreen; + +/// Discriminant attached to each input-field container and inner text entity. +#[derive(Component, Clone, Copy, Debug, PartialEq, Eq)] +enum SyncFieldKind { + Url, + Username, + Password, +} + +/// Per-field raw-text buffer, stored on the inner text entity. +#[derive(Component, Default, Debug)] +struct SyncFieldBuffer(String); + +/// Marker on the error-message text node. +#[derive(Component, Debug)] +struct SyncAuthError; + +/// Marks the "Log In" button. +#[derive(Component, Debug)] +struct SyncLoginButton; + +/// Marks the "Register" button. +#[derive(Component, Debug)] +struct SyncRegisterButton; + +/// Marks the "Cancel" button. +#[derive(Component, Debug)] +struct SyncCancelButton; + +/// Marks the spinner / busy overlay node shown while the auth task is running. +#[derive(Component, Debug)] +struct SyncBusyOverlay; + +// --------------------------------------------------------------------------- +// Resources +// --------------------------------------------------------------------------- + +/// Which field in the sync-setup modal currently has keyboard focus. +#[derive(Resource, Default, Clone, Copy, Debug, PartialEq, Eq)] +enum SyncFocusedField { + #[default] + Url, + Username, + Password, +} + +impl SyncFocusedField { + fn next(self) -> Self { + match self { + Self::Url => Self::Username, + Self::Username => Self::Password, + Self::Password => Self::Url, + } + } + + fn kind(self) -> SyncFieldKind { + match self { + Self::Url => SyncFieldKind::Url, + Self::Username => SyncFieldKind::Username, + Self::Password => SyncFieldKind::Password, + } + } +} + +/// 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. +#[derive(Resource, Default)] +struct PendingAuthTask { + task: Option>>, + url: String, + username: String, +} + +// --------------------------------------------------------------------------- +// Plugin +// --------------------------------------------------------------------------- + +/// Registers the sync configuration UI systems and resources. +pub struct SyncSetupPlugin; + +impl Plugin for SyncSetupPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .init_resource::() + .add_message::() + .add_message::() + .add_message::() + .add_message::() + .add_systems( + Update, + ( + open_sync_setup_modal, + handle_text_input, + update_field_borders, + handle_auth_button, + poll_auth_task, + handle_cancel, + handle_logout, + ) + .chain(), + ); + } +} + +// --------------------------------------------------------------------------- +// Systems +// --------------------------------------------------------------------------- + +/// Opens the sync-setup modal when `SyncConfigureRequestEvent` is received. +fn open_sync_setup_modal( + mut events: MessageReader, + existing: Query<(), With>, + mut commands: Commands, + mut focused: ResMut, + font_res: Option>, +) { + if events.is_empty() { + return; + } + events.clear(); + if !existing.is_empty() { + return; // Already open. + } + *focused = SyncFocusedField::Url; + spawn_sync_setup_modal(&mut commands, font_res.as_deref()); +} + +/// Routes keyboard input to the focused field while the modal is open. +fn handle_text_input( + screen: Query<(), With>, + mut key_events: MessageReader, + mut focused: ResMut, + mut fields: Query<(&SyncFieldKind, &mut SyncFieldBuffer, &mut Text, &mut TextColor)>, + pending: Res, +) { + if screen.is_empty() || pending.task.is_some() { + // Swallow events while modal is closed or auth is in flight. + key_events.clear(); + return; + } + + for ev in key_events.read() { + if ev.state != ButtonState::Pressed { + continue; + } + + // Tab / Shift-Tab cycle focus. + if ev.key_code == KeyCode::Tab { + let shift = ev.logical_key == bevy::input::keyboard::Key::Tab; // no-shift + let _ = shift; // handled below via modifier check + // Bevy doesn't give us the shift modifier state on KeyboardInput directly, + // so we check key_code == Tab and trust that shift produces a separate event. + // Use ButtonInput alternative: we check Tab key here and rely on + // the SyncFocusedField cycling being called per press. + *focused = focused.next(); + continue; + } + + if ev.key_code == KeyCode::Backspace { + for (kind, mut buf, mut text, _) in &mut fields { + if *kind == focused.kind() { + buf.0.pop(); + text.0 = display_text(&buf.0, *kind); + } + } + continue; + } + + // Printable character — append to focused buffer. + if let Some(ch) = ev.text.as_deref().and_then(printable_char) { + for (kind, mut buf, mut text, mut color) in &mut fields { + if *kind == focused.kind() { + if buf.0.len() < 256 { + buf.0.push(ch); + } + text.0 = display_text(&buf.0, *kind); + color.0 = TEXT_PRIMARY; + } + } + } + } +} + +/// Updates the border colour of each input field based on which field is focused. +fn update_field_borders( + screen: Query<(), With>, + focused: Res, + mut borders: Query<(&SyncFieldKind, &mut BorderColor), Without>, +) { + if screen.is_empty() || !focused.is_changed() { + return; + } + for (kind, mut border) in &mut borders { + *border = BorderColor::all(if *kind == focused.kind() { + ACCENT_PRIMARY + } else { + BORDER_SUBTLE + }); + } +} + +/// Fires an async auth task when Login or Register is clicked. +fn handle_auth_button( + login_q: Query<&Interaction, (Changed, With)>, + register_q: Query<&Interaction, (Changed, With)>, + fields: Query<(&SyncFieldKind, &SyncFieldBuffer)>, + mut pending: ResMut, + mut error_nodes: Query<(&mut Text, &mut TextColor), With>, + mut busy_nodes: Query<&mut Visibility, With>, +) { + let login_clicked = login_q + .iter() + .any(|i| *i == Interaction::Pressed); + let register_clicked = register_q + .iter() + .any(|i| *i == Interaction::Pressed); + + if !login_clicked && !register_clicked { + return; + } + if pending.task.is_some() { + return; // Already in flight. + } + + // Collect field values. + let mut url = String::new(); + let mut username = String::new(); + let mut password = String::new(); + for (kind, buf) in &fields { + match kind { + SyncFieldKind::Url => url = buf.0.trim().to_string(), + SyncFieldKind::Username => username = buf.0.trim().to_string(), + SyncFieldKind::Password => password = buf.0.clone(), + } + } + + // Basic validation before hitting the network. + let validation_error = if url.is_empty() { + Some("Server URL is required") + } else if !url.starts_with("http://") && !url.starts_with("https://") { + Some("URL must start with http:// or https://") + } else if username.is_empty() { + Some("Username is required") + } else if password.is_empty() { + Some("Password is required") + } else { + None + }; + + if let Some(msg) = validation_error { + for (mut text, mut color) in &mut error_nodes { + text.0 = msg.to_string(); + color.0 = STATE_DANGER; + } + return; + } + + // Clear error and show busy indicator. + for (mut text, _) in &mut error_nodes { + text.0 = "Connecting…".to_string(); + } + for mut vis in &mut busy_nodes { + *vis = Visibility::Visible; + } + + let is_register = register_clicked; + let client = SolitaireServerClient::new(url.clone(), username.clone()); + let pw = password.clone(); + + let task = 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(async { + if is_register { + client.register(&pw).await + } else { + client.login(&pw).await + } + }) + }); + + pending.task = Some(task); + pending.url = url; + pending.username = username; +} + +/// Polls the in-flight auth task. On success updates settings + provider. +#[allow(clippy::too_many_arguments)] +fn poll_auth_task( + mut pending: ResMut, + mut settings: ResMut, + settings_path: Res, + mut provider: ResMut, + mut error_nodes: Query<(&mut Text, &mut TextColor), With>, + mut busy_nodes: Query<&mut Visibility, With>, + screen: Query>, + mut settings_screen: ResMut, + mut commands: Commands, + mut manual_sync: MessageWriter, + mut toast: MessageWriter, +) { + let Some(task) = pending.task.as_mut() else { + return; + }; + let Some(result) = future::block_on(future::poll_once(task)) else { + return; + }; + pending.task = None; + + for mut vis in &mut busy_nodes { + *vis = Visibility::Hidden; + } + + match result { + Ok((access_token, refresh_token)) => { + let url = pending.url.clone(); + let username = pending.username.clone(); + + // Persist tokens to the OS keychain / Android Keystore. + if let Err(e) = store_tokens(&username, &access_token, &refresh_token) { + for (mut text, mut color) in &mut error_nodes { + text.0 = format!("Token storage failed: {e}"); + color.0 = STATE_DANGER; + } + return; + } + + // Update settings and persist. + settings.0.sync_backend = SyncBackend::SolitaireServer { + url: url.clone(), + username: username.clone(), + }; + if let Some(path) = &settings_path.0 + && let Err(e) = save_settings_to(path, &settings.0) + { + warn!("sync setup: failed to persist settings: {e}"); + } + + // Hot-swap the provider so pull/push use the new credentials. + provider.0 = Arc::new(SolitaireServerClient::new(url, username.clone())); + + // Kick off an immediate pull with the new provider. + manual_sync.write(ManualSyncRequestEvent); + + // Close both the setup modal and the settings panel. + for entity in &screen { + commands.entity(entity).despawn(); + } + settings_screen.0 = false; + + toast.write(InfoToastEvent(format!("Connected as {username}"))); + } + Err(e) => { + let msg = match e { + SyncError::Auth(m) => m, + SyncError::Network(m) => format!("Network error: {m}"), + SyncError::Serialization(m) => format!("Unexpected response: {m}"), + SyncError::UnsupportedPlatform => "Unsupported platform".into(), + }; + for (mut text, mut color) in &mut error_nodes { + text.0 = msg.clone(); + color.0 = STATE_DANGER; + } + } + } +} + +/// Dismisses the sync-setup modal on Cancel click or Escape. +fn handle_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(); + } +} + +/// Clears stored tokens, resets the backend to `Local`, and hot-swaps the +/// provider. Triggered by "Disconnect" in the settings sync section. +fn handle_logout( + mut events: MessageReader, + mut settings: ResMut, + settings_path: Res, + mut provider: ResMut, + mut settings_screen: ResMut, + mut toast: MessageWriter, +) { + if events.is_empty() { + return; + } + events.clear(); + + // Extract username before resetting so we can clear the right keychain key. + let username = match &settings.0.sync_backend { + SyncBackend::SolitaireServer { username, .. } => Some(username.clone()), + SyncBackend::Local => None, + }; + + if let Some(u) = username + && let Err(e) = delete_tokens(&u) + { + warn!("sync logout: failed to clear tokens: {e}"); + } + + settings.0.sync_backend = SyncBackend::Local; + if let Some(path) = &settings_path.0 + && let Err(e) = save_settings_to(path, &settings.0) + { + warn!("sync logout: failed to persist settings: {e}"); + } + + provider.0 = Arc::new(LocalOnlyProvider); + settings_screen.0 = false; + toast.write(InfoToastEvent("Disconnected from sync server".to_string())); +} + +// --------------------------------------------------------------------------- +// UI construction +// --------------------------------------------------------------------------- + +fn spawn_sync_setup_modal(commands: &mut Commands, font_res: Option<&FontResource>) { + spawn_modal(commands, SyncSetupScreen, Z_MODAL_PANEL + 1, |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("Connect to Server"), + make_font(font_res, TYPE_BODY_LG), + TextColor(TEXT_PRIMARY), + )); + }); + + // Scrollable body — three labeled input fields + error line. + card.spawn(Node { + flex_direction: FlexDirection::Column, + row_gap: VAL_SPACE_3, + padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_2), + flex_grow: 1.0, + ..default() + }) + .with_children(|body| { + spawn_field( + body, + SyncFieldKind::Url, + "Server URL", + "https://your-server.example.com", + true, // focused initially + font_res, + ); + spawn_field( + body, + SyncFieldKind::Username, + "Username", + "your-username", + false, + font_res, + ); + spawn_field( + body, + SyncFieldKind::Password, + "Password", + "••••••••", + false, + font_res, + ); + + // Error / status line. + body.spawn(Node { + min_height: Val::Px(18.0), + ..default() + }) + .with_children(|row| { + row.spawn(( + SyncAuthError, + SyncBusyOverlay, + Text::new(String::new()), + make_font(font_res, TYPE_CAPTION), + TextColor(TEXT_SECONDARY), + Visibility::Hidden, + )); + }); + + // Tab hint. + body.spawn(( + Text::new("Tab = next field"), + make_font(font_res, TYPE_CAPTION), + TextColor(TEXT_DISABLED), + )); + }); + + // Action row. + 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, SyncCancelButton, "Cancel", false, font_res); + spawn_action_button(actions, SyncRegisterButton, "Register", false, font_res); + spawn_action_button(actions, SyncLoginButton, "Log In", true, font_res); + }); + }); +} + +fn spawn_field( + parent: &mut ChildSpawnerCommands, + kind: SyncFieldKind, + label: &str, + placeholder: &str, + focused: bool, + font_res: Option<&FontResource>, +) { + parent + .spawn(Node { + flex_direction: FlexDirection::Column, + row_gap: Val::Px(4.0), + ..default() + }) + .with_children(|col| { + // Label. + col.spawn(( + Text::new(label.to_string()), + make_font(font_res, TYPE_CAPTION), + TextColor(TEXT_SECONDARY), + )); + + // Input border container — carries kind for the border-update system. + col.spawn(( + kind, + Node { + border: UiRect::all(Val::Px(1.0)), + border_radius: BorderRadius::all(Val::Px(RADIUS_SM)), + padding: UiRect::axes(VAL_SPACE_2, Val::Px(6.0)), + min_height: Val::Px(32.0), + ..default() + }, + BackgroundColor(BG_ELEVATED), + BorderColor::all(if focused { ACCENT_PRIMARY } else { BORDER_SUBTLE }), + HighContrastBorder::with_default(BORDER_SUBTLE), + )) + .with_children(|border| { + // Inner text / buffer entity. + border.spawn(( + kind, + SyncFieldBuffer(String::new()), + Text::new(placeholder.to_string()), + make_font(font_res, TYPE_BODY), + TextColor(TEXT_DISABLED), + )); + }); + }); +} + +fn spawn_action_button( + parent: &mut ChildSpawnerCommands, + marker: M, + label: &str, + primary: bool, + font_res: Option<&FontResource>, +) { + let bg = if primary { ACCENT_PRIMARY } else { BG_ELEVATED_HI }; + let fg = TEXT_PRIMARY; + parent + .spawn(( + marker, + 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(bg), + BorderColor::all(if primary { ACCENT_PRIMARY } else { BORDER_SUBTLE }), + )) + .with_children(|b| { + b.spawn(( + Text::new(label.to_string()), + make_font(font_res, TYPE_BODY), + TextColor(fg), + )); + }); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn make_font(font_res: Option<&FontResource>, size: f32) -> TextFont { + TextFont { + font: font_res.map(|f| f.0.clone()).unwrap_or_default(), + font_size: size, + ..default() + } +} + +/// Returns the display string for a field — password fields show bullets. +fn display_text(raw: &str, kind: SyncFieldKind) -> String { + if kind == SyncFieldKind::Password { + "•".repeat(raw.len()) + } else { + raw.to_string() + } +} + +/// Extracts a printable ASCII character from a SmolStr keypress text. +fn printable_char(text: &str) -> Option { + let ch = text.chars().next()?; + // Accept printable ASCII: 0x20 (space) through 0x7e (~). + (' '..='~').contains(&ch).then_some(ch) +}