feat(engine): Phase M — sync transparency + local data backup
Closes out the 13-phase menu redesign. Three parts:
1. Post-sync merge summary: the pull poller now keeps the
`ConflictReport`s the merge produces (previously discarded) in a new
`SyncConflictLog` resource and toasts "Synced — N conflicts, kept
newer values" when a merge wasn't clean. No wire changes — the
server-side `SyncResponse.conflicts` field already existed.
2. Account tab detail: a per-field conflict list under the sync row
("win_streak_current — this device: 3 / server: 5") plus a
clean-merge caption, so the last merge is always inspectable.
3. Local data export/import: new `solitaire_data::transfer` module —
one versioned JSON bundle (settings, stats, achievements, progress)
written atomically next to the other saves, with a version-gated
loader that surfaces every failure instead of defaulting. Account
tab grows an Export/Import button pair (desktop+Android only); the
handler runs in `Last`, off the annotated Update spine. Import
rewrites the live resources, persists via the existing save fns,
and fires SettingsChangedEvent so appliers react normally.
Tests: bundle round-trip/version-gate/missing-file in solitaire_data;
ECS-level export→import round trip and failed-import-warns in
settings_plugin. Gate: workspace tests green, clippy -D warnings clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -46,6 +46,7 @@ pub(super) fn spawn_settings_panel(
|
||||
scroll_offset: f32,
|
||||
font_res: Option<&FontResource>,
|
||||
theme_overrides_back: bool,
|
||||
conflict_log: Option<&crate::resources::SyncConflictLog>,
|
||||
active_tab: SettingsTab,
|
||||
) {
|
||||
spawn_modal(commands, SettingsPanel, Z_MODAL_PANEL, |card| {
|
||||
@@ -100,7 +101,9 @@ pub(super) fn spawn_settings_panel(
|
||||
font_res,
|
||||
),
|
||||
SettingsTab::Accessibility => spawn_accessibility_tab(body, settings, font_res),
|
||||
SettingsTab::Account => spawn_account_tab(body, settings, sync_status, font_res),
|
||||
SettingsTab::Account => {
|
||||
spawn_account_tab(body, settings, sync_status, conflict_log, font_res)
|
||||
}
|
||||
});
|
||||
|
||||
// Done is the only action — primary so the player always knows
|
||||
@@ -353,6 +356,7 @@ fn spawn_account_tab(
|
||||
body: &mut ChildSpawnerCommands,
|
||||
settings: &Settings,
|
||||
sync_status: &str,
|
||||
conflict_log: Option<&crate::resources::SyncConflictLog>,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
// --- Privacy (only shown when a Matomo URL is configured) ---
|
||||
@@ -372,6 +376,144 @@ fn spawn_account_tab(
|
||||
// --- Sync ---
|
||||
section_label(body, "Sync", font_res);
|
||||
sync_row(body, sync_status, &settings.sync_backend, font_res);
|
||||
spawn_sync_merge_summary(body, conflict_log, font_res);
|
||||
|
||||
// --- Local data (Phase M) --- backup/restore of the JSON saves;
|
||||
// filesystem-backed, so not offered on the wasm build.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
section_label(body, "Local data", font_res);
|
||||
local_data_row(body, font_res);
|
||||
}
|
||||
}
|
||||
|
||||
/// `Export data` / `Import data` pill buttons plus a caption naming the
|
||||
/// backup file (Phase M). Mirrors the sync row's pill-button styling.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn local_data_row(parent: &mut ChildSpawnerCommands, font_res: Option<&FontResource>) {
|
||||
let button_font = TextFont {
|
||||
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
};
|
||||
let caption_font = button_font.clone();
|
||||
|
||||
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::Column,
|
||||
row_gap: VAL_SPACE_2,
|
||||
..default()
|
||||
})
|
||||
.with_children(|col| {
|
||||
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| {
|
||||
small_button(
|
||||
row,
|
||||
SettingsButton::ExportData,
|
||||
"Export data",
|
||||
"Write settings, stats, achievements and progress to one backup file"
|
||||
.to_string(),
|
||||
button_font.clone(),
|
||||
);
|
||||
small_button(
|
||||
row,
|
||||
SettingsButton::ImportData,
|
||||
"Import data",
|
||||
"Restore all saves from the backup file (replaces current data)".to_string(),
|
||||
button_font.clone(),
|
||||
);
|
||||
});
|
||||
col.spawn((
|
||||
Text::new(format!(
|
||||
"Backup file: {}",
|
||||
solitaire_data::data_bundle_path()
|
||||
.map_or_else(|| "unavailable".to_string(), |p| p.display().to_string())
|
||||
)),
|
||||
caption_font,
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
/// Phase M (sync transparency): renders what the most recent sync merge
|
||||
/// did — a clean-merge caption, or one line per conflicting field with
|
||||
/// both values, so the player can see exactly which numbers were
|
||||
/// reconciled and how.
|
||||
fn spawn_sync_merge_summary(
|
||||
body: &mut ChildSpawnerCommands,
|
||||
conflict_log: Option<&crate::resources::SyncConflictLog>,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
let Some(summary) = conflict_log.and_then(|log| log.0.as_ref()) else {
|
||||
return;
|
||||
};
|
||||
let caption_font = TextFont {
|
||||
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
};
|
||||
let when = summary.at.format("%H:%M UTC");
|
||||
if summary.conflicts.is_empty() {
|
||||
body.spawn((
|
||||
SyncMergeSummaryText,
|
||||
Text::new(format!("Last merge at {when}: no conflicts")),
|
||||
caption_font,
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
return;
|
||||
}
|
||||
let n = summary.conflicts.len();
|
||||
let plural = if n == 1 { "conflict" } else { "conflicts" };
|
||||
body.spawn((
|
||||
SyncMergeSummaryText,
|
||||
Text::new(format!(
|
||||
"Last merge at {when}: {n} {plural} — kept newer values"
|
||||
)),
|
||||
caption_font.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
for c in &summary.conflicts {
|
||||
body.spawn((
|
||||
Text::new(format!(
|
||||
" {} — this device: {} / server: {}",
|
||||
c.field, c.local_value, c.remote_value
|
||||
)),
|
||||
caption_font.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Section divider — small lavender label inside the scrollable body.
|
||||
|
||||
Reference in New Issue
Block a user