//! One-shot "What's new" card on the first launch after an update //! (Phase I of the 2026-07 UI redesign). //! //! ObtainX updates install silently, so shipped features go unnoticed — //! nobody found the theme store on their own. On the first launch where //! the running release differs from `Settings::last_seen_whats_new`, //! this plugin shows a single dismissible card summarising the latest //! changelog section, then records the version so the card never //! repeats. Fresh installs never see it: onboarding completion stamps //! the current version silently, so the card only ever describes an //! *upgrade*. //! //! # Launch beat //! //! Splash → (first run only: onboarding) → **what's new** → Home → //! table. `spawn_home_on_launch` waits on [`WhatsNewPending`] the same //! way it waits for the onboarding modal. //! //! # Version source //! //! The embedded `CHANGELOG.md`'s topmost release section provides both //! the card's content and the "current version" for change detection — //! one source of truth, no build-time version plumbing. (The APK //! `versionName` comes from the release tag at package time and never //! reaches Rust; the workspace `Cargo.toml` version is static.) use bevy::input::ButtonInput; use bevy::prelude::*; use solitaire_data::save_settings_to; use crate::font_plugin::FontResource; use crate::onboarding_plugin::OnboardingScreen; use crate::settings_plugin::{SettingsResource, SettingsStoragePath}; use crate::ui_modal::{ ButtonVariant, ModalScrim, spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, }; use crate::ui_theme::{ TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2, Z_MODAL_PANEL, }; /// The changelog ships inside the binary — small, changes only at /// release cadence, and the card must work offline (§4.2). const CHANGELOG: &str = include_str!("../../CHANGELOG.md"); /// Most bullets shown on the card; a giant release stays skimmable. const MAX_CARD_BULLETS: usize = 8; /// Marker on the What's-new modal's scrim root. #[derive(Component, Debug)] pub struct WhatsNewScreen; /// Marker on the card's "Got it" button. #[derive(Component, Debug)] struct WhatsNewCloseButton; /// `true` until this plugin has made its launch-beat decision — either /// the card was shown and dismissed, or there was nothing to show. /// `spawn_home_on_launch` waits on this so the card gets the beat /// before Home. #[derive(Resource, Debug)] pub struct WhatsNewPending(pub bool); impl Default for WhatsNewPending { fn default() -> Self { Self(true) } } /// One display line of the card. #[derive(Debug, PartialEq, Eq)] enum NoteLine { /// A `###` section heading ("Added", "Fixed", …). Heading(String), /// The lead sentence of one changelog bullet. Bullet(String), } /// The changelog's topmost release section, reduced to card content. #[derive(Debug)] struct ReleaseNotes { /// Version string without the `v` prefix, e.g. `"0.46.0"`. version: String, lines: Vec, } /// The version of the topmost changelog release — the engine's notion /// of "the running release". Empty only if the changelog is malformed. pub fn current_release_version() -> String { parse_latest_release(CHANGELOG).map_or_else(String::new, |notes| notes.version) } /// Parses the first `## [x.y.z]` section of `changelog` into card /// content. `## [Unreleased]` is skipped; `### Internal` subsections /// are dropped (players don't care about CI); each bullet is reduced /// to its lead sentence with markdown emphasis stripped. fn parse_latest_release(changelog: &str) -> Option { let mut lines_iter = changelog.lines(); let mut version = None; for line in lines_iter.by_ref() { if let Some(rest) = line.strip_prefix("## [") { let (v, _) = rest.split_once(']')?; if v.eq_ignore_ascii_case("Unreleased") { continue; } version = Some(v.to_string()); break; } } let version = version?; let mut lines = Vec::new(); let mut skipping_section = false; let mut current_bullet: Option = None; let flush = |bullet: &mut Option, lines: &mut Vec| { if let Some(text) = bullet.take() { lines.push(NoteLine::Bullet(lead_sentence(&text))); } }; for line in lines_iter { if line.starts_with("## [") { break; // next (older) release section } if let Some(heading) = line.strip_prefix("### ") { flush(&mut current_bullet, &mut lines); skipping_section = heading.trim().eq_ignore_ascii_case("internal"); if !skipping_section { lines.push(NoteLine::Heading(heading.trim().to_string())); } continue; } if skipping_section { continue; } if let Some(rest) = line.strip_prefix("- ") { flush(&mut current_bullet, &mut lines); current_bullet = Some(rest.trim().to_string()); } else if current_bullet.is_some() && !line.trim().is_empty() { // Wrapped continuation of the current bullet. if let Some(bullet) = current_bullet.as_mut() { bullet.push(' '); bullet.push_str(line.trim()); } } } flush(&mut current_bullet, &mut lines); Some(ReleaseNotes { version, lines }) } /// Strips markdown emphasis / code ticks and truncates to the first /// sentence — changelog bullets lead with a bold summary sentence, and /// that is exactly the card-sized version. fn lead_sentence(bullet: &str) -> String { let stripped: String = bullet.replace("**", "").replace('`', ""); // Trailing "(#123)" references never survive the sentence cut, but // guard against a bullet that is only a reference. let end = stripped.find(". ").map_or(stripped.len(), |i| i + 1); stripped[..end].trim().trim_end_matches('.').to_string() } /// Registers the launch-gate spawn system and the dismiss handlers. pub struct WhatsNewPlugin; impl Plugin for WhatsNewPlugin { fn build(&self, app: &mut App) { app.init_resource::() .init_resource::>() .add_systems( Update, (maybe_spawn_whats_new, handle_whats_new_close).chain(), ); } } /// Shows the card once the launch surface is clear, or stands down when /// there is nothing to show. See the module docs for the exact beat. /// /// The seen-version stamp persists on *spawn*, not dismissal — if the /// app dies with the card open the player has still seen it once, and /// the card must never nag. #[allow(clippy::too_many_arguments)] fn maybe_spawn_whats_new( mut commands: Commands, mut pending: ResMut, splash: Query<(), With>, onboarding: Query<(), With>, restore_prompts: Query<(), With>, pending_restore: Option>, other_scrims: Query<(), With>, mut settings: Option>, storage_path: Option>, font_res: Option>, screens: Query<(), With>, ) { if !pending.0 { return; } if !screens.is_empty() { // Card already open — the seen-version stamp landed on spawn, // so without this guard the next frame would read "already // seen" and release the beat under the open card. return; } if !splash.is_empty() || !onboarding.is_empty() || !restore_prompts.is_empty() || pending_restore.as_ref().is_some_and(|p| p.0.is_some()) { return; } let Some(settings) = settings.as_mut() else { // Headless / no settings wired: nothing to compare against. pending.0 = false; return; }; if !settings.0.first_run_complete { // Fresh install mid-onboarding — completion stamps the version // itself (see `complete_onboarding`), which resolves this gate // on a later frame without ever showing the card. return; } let Some(notes) = parse_latest_release(CHANGELOG) else { pending.0 = false; return; }; if settings.0.last_seen_whats_new == notes.version { pending.0 = false; return; } if !other_scrims.is_empty() { // Another modal owns the beat right now; try again next frame. return; } spawn_whats_new_card(&mut commands, ¬es, font_res.as_deref()); settings.0.last_seen_whats_new = notes.version.clone(); if let Some(p) = storage_path && let Some(path) = p.0.as_deref() && let Err(e) = save_settings_to(path, &settings.0) { warn!("whats-new: failed to persist seen version: {e}"); } // `pending` stays true until dismissal so Home keeps waiting. } /// Dismisses the card on "Got it" or Esc and releases the launch beat. fn handle_whats_new_close( mut commands: Commands, keys: Option>>, buttons: Query<&Interaction, (With, Changed)>, screens: Query>, other_scrims: Query<(), (With, Without)>, mut pending: ResMut, ) { if screens.is_empty() { return; } let click = buttons.iter().any(|i| *i == Interaction::Pressed); let esc = keys.is_some_and(|k| k.just_pressed(KeyCode::Escape)) && other_scrims.is_empty(); if !click && !esc { return; } for entity in &screens { commands.entity(entity).despawn(); } pending.0 = false; } /// Spawns the card: header with the version, the parsed changelog /// lines, and a "Got it" action. fn spawn_whats_new_card( commands: &mut Commands, notes: &ReleaseNotes, font_res: Option<&FontResource>, ) { let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default(); let font_heading = TextFont { font: font_handle.clone(), font_size: TYPE_CAPTION, ..default() }; let font_bullet = TextFont { font: font_handle, font_size: TYPE_BODY, ..default() }; // Deliberately NOT ScrimDismissible: scrim-tap despawns without // running the close handler, which would leave the launch beat // held and Home never spawning. Esc and "Got it" both release it. let title = format!("What's new in v{}", notes.version); spawn_modal(commands, WhatsNewScreen, Z_MODAL_PANEL, |card| { spawn_modal_header(card, &title, font_res); card.spawn(Node { flex_direction: FlexDirection::Column, row_gap: VAL_SPACE_2, width: Val::Percent(100.0), max_height: Val::Vh(60.0), overflow: Overflow::scroll_y(), ..default() }) .with_children(|body| { let mut bullets_shown = 0usize; for line in ¬es.lines { match line { NoteLine::Heading(heading) => { body.spawn(( Text::new(heading.clone()), font_heading.clone(), TextColor(TEXT_SECONDARY), Node { margin: UiRect::top(VAL_SPACE_1), ..default() }, )); } NoteLine::Bullet(text) => { if bullets_shown >= MAX_CARD_BULLETS { continue; } bullets_shown += 1; body.spawn(( Text::new(format!("\u{2022} {text}")), font_bullet.clone(), TextColor(TEXT_PRIMARY), )); } } } }); spawn_modal_actions(card, |actions| { spawn_modal_button( actions, WhatsNewCloseButton, "Got it", None, ButtonVariant::Primary, font_res, ); }); }); } #[cfg(test)] mod tests { use super::*; use solitaire_data::Settings; const SAMPLE: &str = "# Changelog\n\n## [Unreleased]\n\n## [0.46.0] — 2026-07-13\n\n\ ### Added\n\n- **Theme-store previews.** The store modal now shows each theme's\n \ preview image next to its name. (#179)\n- **Hint ghost preview.** You see the move. (#179)\n\n\ ### Internal\n\n- **CI is faster.** Nobody cares in-game. (#176)\n\n\ ### Fixed\n\n- **Touch onboarding copy.** No more left-click. (#178)\n\n\ ## [0.45.0] — 2026-07-13\n\n### Added\n\n- Old stuff.\n"; #[test] fn parses_top_section_version_and_skips_unreleased() { let notes = parse_latest_release(SAMPLE).expect("sample must parse"); assert_eq!(notes.version, "0.46.0"); } #[test] fn drops_internal_sections_and_older_releases() { let notes = parse_latest_release(SAMPLE).expect("sample must parse"); let headings: Vec<&str> = notes .lines .iter() .filter_map(|l| match l { NoteLine::Heading(h) => Some(h.as_str()), _ => None, }) .collect(); assert_eq!(headings, vec!["Added", "Fixed"]); assert!( !notes .lines .iter() .any(|l| matches!(l, NoteLine::Bullet(b) if b.contains("CI is faster"))), "Internal bullets must not reach the card" ); assert!( !notes .lines .iter() .any(|l| matches!(l, NoteLine::Bullet(b) if b.contains("Old stuff"))), "older release sections must not bleed in" ); } #[test] fn bullets_reduce_to_their_lead_sentence() { let notes = parse_latest_release(SAMPLE).expect("sample must parse"); assert!( notes .lines .contains(&NoteLine::Bullet("Theme-store previews".to_string())) ); assert!( notes .lines .contains(&NoteLine::Bullet("Hint ghost preview".to_string())) ); } #[test] fn embedded_changelog_parses_to_a_nonempty_version() { let version = current_release_version(); assert!( !version.is_empty(), "the real CHANGELOG.md must yield a version" ); assert!( version.chars().next().is_some_and(|c| c.is_ascii_digit()), "version must be bare (no v prefix): {version}" ); } fn app_with(settings: Settings) -> App { let mut app = App::new(); app.add_plugins(MinimalPlugins).add_plugins(WhatsNewPlugin); app.insert_resource(SettingsResource(settings)); app.update(); app } fn card_count(app: &mut App) -> usize { app.world_mut() .query::<&WhatsNewScreen>() .iter(app.world()) .count() } #[test] fn upgrade_shows_card_and_stamps_version() { let mut app = app_with(Settings { first_run_complete: true, last_seen_whats_new: "0.1.0".into(), ..Settings::default() }); app.update(); assert_eq!(card_count(&mut app), 1, "an upgrade must show the card"); assert_eq!( app.world() .resource::() .0 .last_seen_whats_new, current_release_version(), "the seen version must stamp on spawn" ); assert!( app.world().resource::().0, "the launch beat stays held until dismissal" ); } #[test] fn seen_version_shows_nothing_and_releases_the_beat() { let mut app = app_with(Settings { first_run_complete: true, last_seen_whats_new: current_release_version(), ..Settings::default() }); app.update(); assert_eq!(card_count(&mut app), 0); assert!( !app.world().resource::().0, "nothing to show must release the launch beat" ); } #[test] fn fresh_install_waits_for_onboarding_and_never_shows() { let mut app = app_with(Settings { first_run_complete: false, ..Settings::default() }); app.update(); assert_eq!(card_count(&mut app), 0); assert!( app.world().resource::().0, "mid-onboarding the beat stays held" ); // Onboarding completion stamps the version (mirrored from // complete_onboarding) — afterwards the card must stand down. { let mut settings = app.world_mut().resource_mut::(); settings.0.first_run_complete = true; settings.0.last_seen_whats_new = current_release_version(); } app.update(); assert_eq!(card_count(&mut app), 0, "fresh installs never see the card"); assert!(!app.world().resource::().0); } #[test] fn got_it_dismisses_and_releases_the_beat() { let mut app = app_with(Settings { first_run_complete: true, ..Settings::default() }); app.update(); assert_eq!(card_count(&mut app), 1); let button = app .world_mut() .query_filtered::>() .single(app.world()) .expect("Got it button must exist"); app.world_mut() .entity_mut(button) .insert(Interaction::Pressed); app.update(); app.update(); assert_eq!(card_count(&mut app), 0, "Got it must dismiss the card"); assert!(!app.world().resource::().0); } }