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:
@@ -32,7 +32,7 @@ use solitaire_engine::{
|
|||||||
OnboardingPlugin, PausePlugin, PlayBySeedPlugin, ProfilePlugin, ProgressPlugin,
|
OnboardingPlugin, PausePlugin, PlayBySeedPlugin, ProfilePlugin, ProgressPlugin,
|
||||||
RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin, SafeAreaInsetsPlugin,
|
RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin, SafeAreaInsetsPlugin,
|
||||||
SelectionPlugin, SettingsPlugin,
|
SelectionPlugin, SettingsPlugin,
|
||||||
SplashPlugin, StatsPlugin, SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin,
|
SplashPlugin, StatsPlugin, SyncPlugin, SyncSetupPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin,
|
||||||
TimeAttackPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin,
|
TimeAttackPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin,
|
||||||
WinSummaryPlugin,
|
WinSummaryPlugin,
|
||||||
};
|
};
|
||||||
@@ -193,6 +193,7 @@ pub fn run() {
|
|||||||
.add_plugins(AudioPlugin)
|
.add_plugins(AudioPlugin)
|
||||||
.add_plugins(OnboardingPlugin)
|
.add_plugins(OnboardingPlugin)
|
||||||
.add_plugins(SyncPlugin::new(sync_provider))
|
.add_plugins(SyncPlugin::new(sync_provider))
|
||||||
|
.add_plugins(SyncSetupPlugin)
|
||||||
.add_plugins(LeaderboardPlugin)
|
.add_plugins(LeaderboardPlugin)
|
||||||
.add_plugins(WinSummaryPlugin)
|
.add_plugins(WinSummaryPlugin)
|
||||||
.add_plugins(UiModalPlugin)
|
.add_plugins(UiModalPlugin)
|
||||||
|
|||||||
@@ -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.
|
/// Attempt to refresh the access token using the stored refresh token.
|
||||||
///
|
///
|
||||||
/// On success the new access token is persisted to the OS keychain,
|
/// On success the new access token is persisted to the OS keychain,
|
||||||
|
|||||||
@@ -129,6 +129,23 @@ pub struct AchievementUnlockedEvent(pub AchievementRecord);
|
|||||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||||
pub struct ManualSyncRequestEvent;
|
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
|
/// Request to toggle the pause overlay. Fired by the HUD "Pause" button so
|
||||||
/// the same toggle path runs whether the player presses `Esc` or clicks.
|
/// the same toggle path runs whether the player presses `Esc` or clicks.
|
||||||
/// Consumed by `pause_plugin::toggle_pause`, which honours the same drag /
|
/// Consumed by `pause_plugin::toggle_pause`, which honours the same drag /
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ pub mod selection_plugin;
|
|||||||
pub mod splash_plugin;
|
pub mod splash_plugin;
|
||||||
pub mod stats_plugin;
|
pub mod stats_plugin;
|
||||||
pub mod sync_plugin;
|
pub mod sync_plugin;
|
||||||
|
pub mod sync_setup_plugin;
|
||||||
pub mod table_plugin;
|
pub mod table_plugin;
|
||||||
pub mod theme;
|
pub mod theme;
|
||||||
pub mod time_attack_plugin;
|
pub mod time_attack_plugin;
|
||||||
@@ -150,6 +151,7 @@ pub use stats_plugin::{
|
|||||||
StatsScreen, StatsUpdate, WatchReplayButton,
|
StatsScreen, StatsUpdate, WatchReplayButton,
|
||||||
};
|
};
|
||||||
pub use sync_plugin::{SyncPlugin, SyncProviderResource};
|
pub use sync_plugin::{SyncPlugin, SyncProviderResource};
|
||||||
|
pub use sync_setup_plugin::SyncSetupPlugin;
|
||||||
pub use ui_focus::{Disabled, FocusGroup, Focusable, FocusedButton, UiFocusPlugin};
|
pub use ui_focus::{Disabled, FocusGroup, Focusable, FocusedButton, UiFocusPlugin};
|
||||||
pub use ui_modal::{
|
pub use ui_modal::{
|
||||||
spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
|
spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
|
||||||
|
|||||||
@@ -22,7 +22,12 @@ use solitaire_data::{
|
|||||||
TOOLTIP_DELAY_STEP_SECS,
|
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::font_plugin::FontResource;
|
||||||
use crate::progress_plugin::ProgressResource;
|
use crate::progress_plugin::ProgressResource;
|
||||||
use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource};
|
use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource};
|
||||||
@@ -231,6 +236,10 @@ enum SettingsButton {
|
|||||||
/// player's last window size always wins.
|
/// player's last window size always wins.
|
||||||
ToggleSmartDefaultSize,
|
ToggleSmartDefaultSize,
|
||||||
SyncNow,
|
SyncNow,
|
||||||
|
/// Open the sync-server Connect modal (shown when backend = Local).
|
||||||
|
ConnectSync,
|
||||||
|
/// Disconnect from the sync server (shown when backend = SolitaireServer).
|
||||||
|
DisconnectSync,
|
||||||
Done,
|
Done,
|
||||||
/// Select a specific card-back by index from the picker row.
|
/// Select a specific card-back by index from the picker row.
|
||||||
SelectCardBack(usize),
|
SelectCardBack(usize),
|
||||||
@@ -284,6 +293,8 @@ impl SettingsButton {
|
|||||||
SettingsButton::SelectTheme(_) => 85,
|
SettingsButton::SelectTheme(_) => 85,
|
||||||
// Sync section
|
// Sync section
|
||||||
SettingsButton::SyncNow => 90,
|
SettingsButton::SyncNow => 90,
|
||||||
|
SettingsButton::ConnectSync => 91,
|
||||||
|
SettingsButton::DisconnectSync => 92,
|
||||||
// Done is tagged by `attach_focusable_to_modal_buttons` and
|
// Done is tagged by `attach_focusable_to_modal_buttons` and
|
||||||
// never reaches `attach_focusable_to_settings_buttons`; the
|
// never reaches `attach_focusable_to_settings_buttons`; the
|
||||||
// value here is only a fallback for completeness.
|
// value here is only a fallback for completeness.
|
||||||
@@ -333,6 +344,8 @@ impl Plugin for SettingsPlugin {
|
|||||||
.init_resource::<PendingWindowGeometry>()
|
.init_resource::<PendingWindowGeometry>()
|
||||||
.add_message::<SettingsChangedEvent>()
|
.add_message::<SettingsChangedEvent>()
|
||||||
.add_message::<ManualSyncRequestEvent>()
|
.add_message::<ManualSyncRequestEvent>()
|
||||||
|
.add_message::<SyncConfigureRequestEvent>()
|
||||||
|
.add_message::<SyncLogoutRequestEvent>()
|
||||||
.add_message::<ToggleSettingsRequestEvent>()
|
.add_message::<ToggleSettingsRequestEvent>()
|
||||||
.add_message::<InfoToastEvent>()
|
.add_message::<InfoToastEvent>()
|
||||||
.add_message::<bevy::input::mouse::MouseWheel>()
|
.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 color_blind_text: Query<&mut Text, (With<ColorBlindText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<HighContrastText>, Without<ReduceMotionText>)>,
|
||||||
mut high_contrast_text: Query<&mut Text, (With<HighContrastText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>, Without<ReduceMotionText>)>,
|
mut high_contrast_text: Query<&mut Text, (With<HighContrastText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>, Without<ReduceMotionText>)>,
|
||||||
mut reduce_motion_text: Query<&mut Text, (With<ReduceMotionText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>, Without<HighContrastText>)>,
|
mut reduce_motion_text: Query<&mut Text, (With<ReduceMotionText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>, Without<HighContrastText>)>,
|
||||||
|
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
|
||||||
|
mut logout_sync: MessageWriter<SyncLogoutRequestEvent>,
|
||||||
) {
|
) {
|
||||||
for (interaction, button) in &interaction_query {
|
for (interaction, button) in &interaction_query {
|
||||||
if *interaction != Interaction::Pressed {
|
if *interaction != Interaction::Pressed {
|
||||||
@@ -1056,6 +1071,12 @@ fn handle_settings_buttons(
|
|||||||
SettingsButton::SyncNow => {
|
SettingsButton::SyncNow => {
|
||||||
manual_sync.write(ManualSyncRequestEvent);
|
manual_sync.write(ManualSyncRequestEvent);
|
||||||
}
|
}
|
||||||
|
SettingsButton::ConnectSync => {
|
||||||
|
configure_sync.write(SyncConfigureRequestEvent);
|
||||||
|
}
|
||||||
|
SettingsButton::DisconnectSync => {
|
||||||
|
logout_sync.write(SyncLogoutRequestEvent);
|
||||||
|
}
|
||||||
SettingsButton::Done => {
|
SettingsButton::Done => {
|
||||||
screen.0 = false;
|
screen.0 = false;
|
||||||
}
|
}
|
||||||
@@ -1596,7 +1617,7 @@ fn spawn_settings_panel(
|
|||||||
|
|
||||||
// --- Sync ---
|
// --- Sync ---
|
||||||
section_label(body, "Sync", font_res);
|
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
|
// 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.
|
/// Sync section row — shows different controls depending on whether a server
|
||||||
fn sync_row(parent: &mut ChildSpawnerCommands, status_text: &str, font_res: Option<&FontResource>) {
|
/// backend is configured.
|
||||||
|
fn sync_row(
|
||||||
|
parent: &mut ChildSpawnerCommands,
|
||||||
|
status_text: &str,
|
||||||
|
backend: &SyncBackend,
|
||||||
|
font_res: Option<&FontResource>,
|
||||||
|
) {
|
||||||
let status_font = TextFont {
|
let status_font = TextFont {
|
||||||
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||||
font_size: TYPE_BODY,
|
font_size: TYPE_BODY,
|
||||||
@@ -2220,45 +2247,98 @@ fn sync_row(parent: &mut ChildSpawnerCommands, status_text: &str, font_res: Opti
|
|||||||
font_size: TYPE_CAPTION,
|
font_size: TYPE_CAPTION,
|
||||||
..default()
|
..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
|
parent
|
||||||
.spawn(Node {
|
.spawn(Node {
|
||||||
flex_direction: FlexDirection::Row,
|
flex_direction: FlexDirection::Column,
|
||||||
align_items: AlignItems::Center,
|
row_gap: VAL_SPACE_2,
|
||||||
column_gap: VAL_SPACE_3,
|
|
||||||
..default()
|
..default()
|
||||||
})
|
})
|
||||||
.with_children(|row| {
|
.with_children(|col| {
|
||||||
row.spawn((
|
// Status line + inline action buttons.
|
||||||
SyncStatusText,
|
col.spawn(Node {
|
||||||
Text::new(status_text.to_string()),
|
flex_direction: FlexDirection::Row,
|
||||||
status_font,
|
align_items: AlignItems::Center,
|
||||||
TextColor(TEXT_SECONDARY),
|
column_gap: VAL_SPACE_3,
|
||||||
));
|
flex_wrap: FlexWrap::Wrap,
|
||||||
// ManualSyncRequestEvent is always registered, so this
|
row_gap: VAL_SPACE_2,
|
||||||
// button is safe to show even when SyncPlugin is absent.
|
..default()
|
||||||
row.spawn((
|
})
|
||||||
SettingsButton::SyncNow,
|
.with_children(|row| {
|
||||||
Button,
|
row.spawn((
|
||||||
Tooltip::new(
|
SyncStatusText,
|
||||||
"Push and pull stats now. Runs automatically on launch and exit.",
|
Text::new(status_text.to_string()),
|
||||||
),
|
status_font,
|
||||||
Node {
|
TextColor(TEXT_SECONDARY),
|
||||||
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),
|
|
||||||
));
|
));
|
||||||
|
|
||||||
|
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}"
|
"expected the panel to spawn many tooltipped buttons; got {tipped_count}"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Spot-check: the Sync Now button's tooltip text is the
|
// Spot-check: with default (Local) settings the Connect button
|
||||||
// canonical microcopy. We find it via the `SettingsButton`
|
// spawns. We verify its tooltip carries the canonical microcopy.
|
||||||
// discriminant — there is exactly one Sync Now entity per panel.
|
let connect_tip = app
|
||||||
let sync_tip = app
|
|
||||||
.world_mut()
|
.world_mut()
|
||||||
.query::<(&SettingsButton, &Tooltip)>()
|
.query::<(&SettingsButton, &Tooltip)>()
|
||||||
.iter(app.world())
|
.iter(app.world())
|
||||||
.find_map(|(btn, tip)| matches!(btn, SettingsButton::SyncNow).then(|| tip.0.clone()))
|
.find_map(|(btn, tip)| {
|
||||||
.expect("Sync Now button should spawn with a Tooltip");
|
matches!(btn, SettingsButton::ConnectSync).then(|| tip.0.clone())
|
||||||
|
})
|
||||||
|
.expect("Connect button should spawn with a Tooltip when backend is Local");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
sync_tip.as_ref(),
|
connect_tip.as_ref(),
|
||||||
"Push and pull stats now. Runs automatically on launch and exit.",
|
"Connect to a self-hosted Solitaire Quest sync server.",
|
||||||
"Sync Now tooltip must use the canonical microcopy"
|
"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