feat(sync): Phase 8 sync setup UI — login/register modal + Connect/Disconnect
Adds SyncSetupPlugin: a three-field (URL / Username / Password) modal that handles both login and register flows via an async task on AsyncComputeTaskPool wrapped in a Tokio single-thread runtime (same pattern as the existing sync push/pull). On success, tokens are stored to the OS keychain / Android Keystore and SyncProviderResource is hot-swapped so subsequent pull/push use the new credentials immediately. Settings sync section now shows Connect (when Local) or Sync Now + Disconnect + username label (when SolitaireServer). SyncAuthResultEvent stub registered for future re-auth prompt wiring. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<String, String>);
|
||||
|
||||
/// 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 /
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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::<PendingWindowGeometry>()
|
||||
.add_message::<SettingsChangedEvent>()
|
||||
.add_message::<ManualSyncRequestEvent>()
|
||||
.add_message::<SyncConfigureRequestEvent>()
|
||||
.add_message::<SyncLogoutRequestEvent>()
|
||||
.add_message::<ToggleSettingsRequestEvent>()
|
||||
.add_message::<InfoToastEvent>()
|
||||
.add_message::<bevy::input::mouse::MouseWheel>()
|
||||
@@ -849,6 +862,8 @@ 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 {
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Task<Result<(String, String), SyncError>>>,
|
||||
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::<SyncFocusedField>()
|
||||
.init_resource::<PendingAuthTask>()
|
||||
.add_message::<SyncConfigureRequestEvent>()
|
||||
.add_message::<SyncLogoutRequestEvent>()
|
||||
.add_message::<ManualSyncRequestEvent>()
|
||||
.add_message::<InfoToastEvent>()
|
||||
.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<SyncConfigureRequestEvent>,
|
||||
existing: Query<(), With<SyncSetupScreen>>,
|
||||
mut commands: Commands,
|
||||
mut focused: ResMut<SyncFocusedField>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
) {
|
||||
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<SyncSetupScreen>>,
|
||||
mut key_events: MessageReader<KeyboardInput>,
|
||||
mut focused: ResMut<SyncFocusedField>,
|
||||
mut fields: Query<(&SyncFieldKind, &mut SyncFieldBuffer, &mut Text, &mut TextColor)>,
|
||||
pending: Res<PendingAuthTask>,
|
||||
) {
|
||||
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<KeyCode> 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<SyncSetupScreen>>,
|
||||
focused: Res<SyncFocusedField>,
|
||||
mut borders: Query<(&SyncFieldKind, &mut BorderColor), Without<SyncFieldBuffer>>,
|
||||
) {
|
||||
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<Interaction>, With<SyncLoginButton>)>,
|
||||
register_q: Query<&Interaction, (Changed<Interaction>, With<SyncRegisterButton>)>,
|
||||
fields: Query<(&SyncFieldKind, &SyncFieldBuffer)>,
|
||||
mut pending: ResMut<PendingAuthTask>,
|
||||
mut error_nodes: Query<(&mut Text, &mut TextColor), With<SyncAuthError>>,
|
||||
mut busy_nodes: Query<&mut Visibility, With<SyncBusyOverlay>>,
|
||||
) {
|
||||
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<PendingAuthTask>,
|
||||
mut settings: ResMut<SettingsResource>,
|
||||
settings_path: Res<SettingsStoragePath>,
|
||||
mut provider: ResMut<SyncProviderResource>,
|
||||
mut error_nodes: Query<(&mut Text, &mut TextColor), With<SyncAuthError>>,
|
||||
mut busy_nodes: Query<&mut Visibility, With<SyncBusyOverlay>>,
|
||||
screen: Query<Entity, With<SyncSetupScreen>>,
|
||||
mut settings_screen: ResMut<SettingsScreen>,
|
||||
mut commands: Commands,
|
||||
mut manual_sync: MessageWriter<ManualSyncRequestEvent>,
|
||||
mut toast: MessageWriter<InfoToastEvent>,
|
||||
) {
|
||||
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<Interaction>, With<SyncCancelButton>)>,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
screen: Query<Entity, With<SyncSetupScreen>>,
|
||||
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<SyncLogoutRequestEvent>,
|
||||
mut settings: ResMut<SettingsResource>,
|
||||
settings_path: Res<SettingsStoragePath>,
|
||||
mut provider: ResMut<SyncProviderResource>,
|
||||
mut settings_screen: ResMut<SettingsScreen>,
|
||||
mut toast: MessageWriter<InfoToastEvent>,
|
||||
) {
|
||||
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<M: Component>(
|
||||
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<char> {
|
||||
let ch = text.chars().next()?;
|
||||
// Accept printable ASCII: 0x20 (space) through 0x7e (~).
|
||||
(' '..='~').contains(&ch).then_some(ch)
|
||||
}
|
||||
Reference in New Issue
Block a user