fix(bienvenida): wire the welcome screen into the first-launch flow

This commit is contained in:
2026-07-29 15:49:44 +02:00
parent f3d744aeed
commit 2959941485
6 changed files with 265 additions and 8 deletions
+21 -4
View File
@@ -14,6 +14,7 @@ import 'l10n/gen/app_localizations.dart';
import 'modelos/alarma_musical.dart'; import 'modelos/alarma_musical.dart';
import 'pantallas/pantalla_alarmas.dart'; import 'pantallas/pantalla_alarmas.dart';
import 'pantallas/pantalla_alarma_sonando.dart'; import 'pantallas/pantalla_alarma_sonando.dart';
import 'pantallas/pantalla_bienvenida.dart';
import 'pantallas/pantalla_inicio.dart'; import 'pantallas/pantalla_inicio.dart';
import 'pantallas/pantalla_buscar.dart'; import 'pantallas/pantalla_buscar.dart';
import 'pantallas/pantalla_favoritos.dart'; import 'pantallas/pantalla_favoritos.dart';
@@ -108,7 +109,10 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal>
EstadoRadio? _estadoSuscrito; EstadoRadio? _estadoSuscrito;
bool _alarmaInicialProcesada = false; bool _alarmaInicialProcesada = false;
bool _alarmaSonandoActiva = false; bool _alarmaSonandoActiva = false;
bool _onboardingInicialSolicitado = false; // WU17b: renamed from `_onboardingInicialSolicitado` — this single guard
// now covers the whole first-launch sequence (welcome screen, then the
// pre-existing what's-new dialog), not only the dialog.
bool _flujoPrimerLanzamientoSolicitado = false;
String? _alarmaSonandoId; String? _alarmaSonandoId;
Locale? _localeAlarmasConfigurado; Locale? _localeAlarmasConfigurado;
@@ -191,9 +195,9 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal>
_alarmaInicialProcesada = true; _alarmaInicialProcesada = true;
unawaited(_procesarAlarmaInicial(alarmas)); unawaited(_procesarAlarmaInicial(alarmas));
} }
if (!_onboardingInicialSolicitado) { if (!_flujoPrimerLanzamientoSolicitado) {
_onboardingInicialSolicitado = true; _flujoPrimerLanzamientoSolicitado = true;
unawaited(_mostrarOnboardingInicial()); unawaited(_mostrarFlujoPrimerLanzamiento());
} }
} }
@@ -278,6 +282,19 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal>
} }
} }
// WU17b: runs the welcome screen's once-ever check BEFORE the recurring
// what's-new dialog, so the two never show at the same time. The welcome
// screen (PantallaBienvenida) is the genuine first-run surface; the
// pre-existing PluriOnboardingDialog is an unrelated "what's new"/help
// modal that keeps its own independent per-version due-or-not logic,
// completely unchanged by this sequencing.
Future<void> _mostrarFlujoPrimerLanzamiento() async {
if (mounted) {
await PantallaBienvenida.mostrarSiProcede(context);
}
await _mostrarOnboardingInicial();
}
Future<void> _mostrarOnboardingInicial() async { Future<void> _mostrarOnboardingInicial() async {
await Future<void>.delayed(const Duration(milliseconds: 900)); await Future<void>.delayed(const Duration(milliseconds: 900));
if (!mounted || _alarmaSonandoActiva) return; if (!mounted || _alarmaSonandoActiva) return;
+23 -4
View File
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
import '../estado/estado_navegacion.dart'; import '../estado/estado_navegacion.dart';
import '../l10n/gen/app_localizations.dart'; import '../l10n/gen/app_localizations.dart';
import '../servicios/servicio_bienvenida.dart';
import '../tema/pluriwave_theme.dart'; import '../tema/pluriwave_theme.dart';
/// WU17: first-run welcome surface (`onboarding-welcome` spec, mockup /// WU17: first-run welcome surface (`onboarding-welcome` spec, mockup
@@ -14,13 +15,31 @@ import '../tema/pluriwave_theme.dart';
/// ///
/// A full-screen ROUTE, not a modal `Dialog` — deliberately distinct from /// A full-screen ROUTE, not a modal `Dialog` — deliberately distinct from
/// the pre-existing `PluriOnboardingDialog` (an unrelated "what's new" /// the pre-existing `PluriOnboardingDialog` (an unrelated "what's new"
/// help-content modal already shown from `app.dart`'s launch flow). This /// help-content modal already shown from `app.dart`'s launch flow). Both
/// work unit's own task list and verify command scope to this screen in /// surfaces coexist: [mostrarSiProcede] (WU17b) is what actually wires this
/// isolation; wiring it into the real first-launch flow (i.e. deciding /// screen into the genuine first-launch flow, called from `app.dart` BEFORE
/// when `app.dart`/`main.dart` should push it) is not part of WU17. /// `PluriOnboardingDialog.mostrarSiProcede` on every cold start, so the two
/// never race — the once-ever welcome resolves first, then the recurring
/// what's-new dialog runs its own unrelated per-version check exactly as
/// before.
class PantallaBienvenida extends StatelessWidget { class PantallaBienvenida extends StatelessWidget {
const PantallaBienvenida({super.key}); const PantallaBienvenida({super.key});
static final ServicioBienvenida _servicio = ServicioBienvenida();
/// WU17b: shows this screen once, on the genuine first launch, then never
/// again. Mirrors `PluriOnboardingDialog.mostrarSiProcede`'s shape
/// (check-then-show-then-mark-seen) so both first-launch surfaces share
/// the same call convention from `app.dart`.
static Future<void> mostrarSiProcede(BuildContext context) async {
if (!await _servicio.debeMostrarBienvenida()) return;
if (!context.mounted) return;
await Navigator.of(
context,
).push(MaterialPageRoute<void>(builder: (_) => const PantallaBienvenida()));
await _servicio.marcarBienvenidaVista();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
+32
View File
@@ -0,0 +1,32 @@
import 'package:shared_preferences/shared_preferences.dart';
/// WU17b: persists whether the first-run welcome screen (`PantallaBienvenida`,
/// `onboarding-welcome` spec) has already been shown, so it renders once
/// rather than on every launch.
///
/// Same injectable-`SharedPreferences`, versioned-key convention as
/// `ServicioContenidoApp` (S3-R4) — but a plain one-time boolean flag, since
/// this welcome screen is a single first-impression surface, not something
/// that re-triggers per app version the way the "what's new" onboarding
/// dialog does.
class ServicioBienvenida {
ServicioBienvenida({SharedPreferences? prefs}) : _prefs = prefs;
static const _keyBienvenidaVista = 'pluri_bienvenida_vista_v1';
final SharedPreferences? _prefs;
/// Injected startup instance (S3-R4); getInstance() is only a fallback.
Future<SharedPreferences> _resolverPrefs() async =>
_prefs ?? SharedPreferences.getInstance();
Future<bool> debeMostrarBienvenida() async {
final prefs = await _resolverPrefs();
return !(prefs.getBool(_keyBienvenidaVista) ?? false);
}
Future<void> marcarBienvenidaVista() async {
final prefs = await _resolverPrefs();
await prefs.setBool(_keyBienvenidaVista, true);
}
}
@@ -16,6 +16,11 @@
> **WU15b was added mid-apply, not planned upfront** — WU15 shipped `PantallaGrabaciones` (the recordings library) > **WU15b was added mid-apply, not planned upfront** — WU15 shipped `PantallaGrabaciones` (the recordings library)
> fully tested but reachable from nowhere in the app. WU15b (below, after WU15's section) is the coordinator-ruled fix > fully tested but reachable from nowhere in the app. WU15b (below, after WU15's section) is the coordinator-ruled fix
> that wires it into Settings navigation. It is small and does not change the 18-commit delivery model's shape. > that wires it into Settings navigation. It is small and does not change the 18-commit delivery model's shape.
> **WU17b was added mid-apply, not planned upfront — same shape as WU15b.** WU17 shipped `PantallaBienvenida` (the
> welcome screen) fully tested but reachable from nowhere in the app (`rg "PantallaBienvenida" lib/app.dart lib/main.dart`
> found nothing). WU17b (below, after WU17's section) wires it into the genuine first-launch flow and establishes how
> it coexists with the pre-existing, unrelated `PluriOnboardingDialog` ("what's new" modal). It is small and does not
> change the 18-commit delivery model's shape.
> Strict TDD is ON. Runner: `flutter test`. `flutter analyze` and a **scoped** `dart format` gate every commit. > Strict TDD is ON. Runner: `flutter test`. `flutter analyze` and a **scoped** `dart format` gate every commit.
> **`flutter build` is never run.** > **`flutter build` is never run.**
> >
@@ -1058,6 +1063,61 @@ real capability living in the header, not decorative chrome).
`lib/l10n/app_*.arb` or `lib/`. `lib/l10n/app_*.arb` or `lib/`.
- [x] 17.7 Verify — all 3 scenario tests green; grep scan clean. - [x] 17.7 Verify — all 3 scenario tests green; grep scan clean.
## WU17b — Wire the welcome screen into the first-launch flow
**Not in the original plan — added to close the WU17 gap noted above, same shape as WU15/WU15b.** WU17 built
`PantallaBienvenida` fully tested and committed, but left it unreachable from the app: `rg "PantallaBienvenida"
lib/app.dart lib/main.dart` found nothing. This work unit exists solely to fix that.
**Commit**: `fix(bienvenida): wire the welcome screen into the first-launch flow`
**Depends on**: WU17
**Spec refs**: `onboarding-welcome` — Full-Screen Welcome Route (reachability; the render/content/no-monetization/CTA
scenarios stay WU17's own, unmodified)
**Verify**: `flutter test test/servicios/servicio_bienvenida_test.dart test/pantallas/pantalla_bienvenida_primer_lanzamiento_test.dart test/pantallas/pantalla_bienvenida_test.dart test/widget_test.dart && flutter analyze && dart format --set-exit-if-changed $(git diff --cached --name-only --diff-filter=ACM HEAD -- '*.dart')`
**New tests**: `test/servicios/servicio_bienvenida_test.dart`, `test/pantallas/pantalla_bienvenida_primer_lanzamiento_test.dart`
**Modified tests**: none (WU17's own `pantalla_bienvenida_test.dart` re-run unmodified as a regression check)
**Coexistence ruling applied.** `PluriOnboardingDialog` (an unrelated, pre-existing "what's new"/help-content modal
loaded from markdown assets) is NOT deleted, merged, or replaced — first-launch welcome and a what's-new modal are
different things, confirmed by reading `assets/content/onboarding/en.md`: it is a detailed feature-reference
walkthrough plus per-version update notes, structurally different content from the welcome screen's 3-bullet
marketing intro. Both now run from `app.dart`'s `_PaginaPrincipalState`, in this order on every cold start: the
welcome screen's once-ever check resolves FIRST, then the pre-existing what's-new dialog's own independent
per-version due-or-not check runs exactly as it did before this WU. Sequencing (not two independent fire-and-forget
calls) is what prevents the two from ever racing onto the screen at the same time.
- [x] 17b.1 RED — `test/servicios/servicio_bienvenida_test.dart`: a new `ServicioBienvenida` (mirroring
`ServicioContenidoApp`'s injectable-`SharedPreferences`, versioned-key convention, S3-R4) is due before it has
ever been marked seen, is not due after `marcarBienvenidaVista()`, and respects a seen flag already persisted
by a prior launch (`SharedPreferences.setMockInitialValues`).
- [x] 17b.2 RED — `test/pantallas/pantalla_bienvenida_primer_lanzamiento_test.dart`: a new
`PantallaBienvenida.mostrarSiProcede(context)` static method (mirroring `PluriOnboardingDialog.mostrarSiProcede`'s
check-then-show-then-mark-seen shape) pushes the welcome screen on a first launch (no seen flag persisted), does
NOT push it when the seen flag is already persisted, and — in one continuous session — persists the flag after
being shown once so a later check in the same run skips it.
- [x] 17b.3 GREEN — created `lib/servicios/servicio_bienvenida.dart` (`ServicioBienvenida`, key
`pluri_bienvenida_vista_v1`, plain one-time boolean — no version comparison needed, unlike
`ServicioContenidoApp`, since this welcome is a single first-impression surface, not a per-version one).
- [x] 17b.4 GREEN — added the static `PantallaBienvenida.mostrarSiProcede(BuildContext)` method to the existing
`pantalla_bienvenida.dart` file (no new wrapper class needed — unlike `PluriOnboardingDialog`, there is only one
call shape here); updated the class doc comment to describe how it coexists with `PluriOnboardingDialog`.
- [x] 17b.5 GREEN — wired `app.dart`: imported `pantalla_bienvenida.dart`; renamed the existing
`_onboardingInicialSolicitado` guard flag to `_flujoPrimerLanzamientoSolicitado` (it now covers the combined
sequence, not only the dialog); added `_mostrarFlujoPrimerLanzamiento()`, which awaits
`PantallaBienvenida.mostrarSiProcede(context)` then calls the pre-existing, untouched
`_mostrarOnboardingInicial()` — replacing the single `unawaited(_mostrarOnboardingInicial())` call site with
`unawaited(_mostrarFlujoPrimerLanzamiento())`.
- [x] 17b.6 REFACTOR — confirmed `_mostrarOnboardingInicial()`'s own body (900ms delay, `_alarmaSonandoActiva` guard,
`PluriOnboardingDialog.mostrarSiProcede` call) is byte-for-byte unchanged — only its call site moved one level
deeper into the new sequencing method. No scenario in `PantallaBienvenida`'s own WU17 test file needed to
change (all 3 pass unmodified, confirming the CTA/content/no-monetization behavior is untouched).
- [x] 17b.7 Verify — scoped suite green: 13/13 (3 `servicio_bienvenida_test.dart` + 3
`pantalla_bienvenida_primer_lanzamiento_test.dart` + 3 `pantalla_bienvenida_test.dart` [byte-identical,
unmodified] + 4 `widget_test.dart`). `flutter analyze`: 1 issue, identical to baseline. Scoped `dart format`:
reformatted 1 of 5 touched files (whitespace-only string-literal wrapping), stable on re-run. Literal-encoding
scan: one console-rendering false positive on the pre-existing "días" string (verified byte-correct UTF-8 via a
direct file read with the encoding pinned), zero real corruption.
## WU18 — i18n batch (all 13 locales) ## WU18 — i18n batch (all 13 locales)
**Commit**: `feat(i18n): add redesign strings and translate Escuchar rename to 11 locales` **Commit**: `feat(i18n): add redesign strings and translate Escuchar rename to 11 locales`
@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/pantallas/pantalla_bienvenida.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// WU17b: `PantallaBienvenida.mostrarSiProcede` wires the welcome screen
/// into the genuine first-launch flow. WU17 built and tested the screen
/// fully in isolation, but nothing in `lib/app.dart` or `lib/main.dart`
/// ever referenced it — it was unreachable. These tests pin the actual
/// gap: the screen must show on a genuine first launch and never again
/// once `ServicioBienvenida` has recorded it as seen.
Widget _appConDisparador(GlobalKey<NavigatorState> navigatorKey) {
return MaterialApp(
navigatorKey: navigatorKey,
locale: const Locale('es'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder:
(context) => Scaffold(
body: Center(
child: ElevatedButton(
onPressed: () => PantallaBienvenida.mostrarSiProcede(context),
child: const Text('disparar'),
),
),
),
),
);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
testWidgets(
'first launch (no seen flag persisted) shows the welcome screen',
(tester) async {
SharedPreferences.setMockInitialValues({});
final navigatorKey = GlobalKey<NavigatorState>();
await tester.pumpWidget(_appConDisparador(navigatorKey));
await tester.tap(find.text('disparar'));
await tester.pumpAndSettle();
expect(find.byType(PantallaBienvenida), findsOneWidget);
},
);
testWidgets(
'second launch (seen flag already persisted) does not show it again',
(tester) async {
SharedPreferences.setMockInitialValues({
'pluri_bienvenida_vista_v1': true,
});
final navigatorKey = GlobalKey<NavigatorState>();
await tester.pumpWidget(_appConDisparador(navigatorKey));
await tester.tap(find.text('disparar'));
await tester.pumpAndSettle();
expect(find.byType(PantallaBienvenida), findsNothing);
},
);
testWidgets('showing it once persists the flag so a later check in the same '
'session skips it', (tester) async {
SharedPreferences.setMockInitialValues({});
final navigatorKey = GlobalKey<NavigatorState>();
await tester.pumpWidget(_appConDisparador(navigatorKey));
await tester.tap(find.text('disparar'));
await tester.pumpAndSettle();
expect(find.byType(PantallaBienvenida), findsOneWidget);
// Dismiss it the same way the CTA does (a plain pop), simulating the
// welcome screen being resolved before the next check ever happens.
navigatorKey.currentState!.pop();
await tester.pumpAndSettle();
await tester.tap(find.text('disparar'));
await tester.pumpAndSettle();
expect(find.byType(PantallaBienvenida), findsNothing);
});
}
@@ -0,0 +1,44 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/servicios/servicio_bienvenida.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// WU17b: `ServicioBienvenida` decides whether the first-run welcome
/// screen (`PantallaBienvenida`) is still due — same injectable-prefs,
/// versioned-key convention as `ServicioContenidoApp` (S3-R4), but a plain
/// boolean flag since this welcome is a one-time, not per-version, surface.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('ServicioBienvenida', () {
test(
'debeMostrarBienvenida is true before it has ever been marked seen',
() async {
SharedPreferences.setMockInitialValues({});
final servicio = ServicioBienvenida();
expect(await servicio.debeMostrarBienvenida(), isTrue);
},
);
test(
'debeMostrarBienvenida is false after marcarBienvenidaVista',
() async {
SharedPreferences.setMockInitialValues({});
final servicio = ServicioBienvenida();
await servicio.marcarBienvenidaVista();
expect(await servicio.debeMostrarBienvenida(), isFalse);
},
);
test('respects a seen flag already persisted by a prior launch', () async {
SharedPreferences.setMockInitialValues({
'pluri_bienvenida_vista_v1': true,
});
final servicio = ServicioBienvenida();
expect(await servicio.debeMostrarBienvenida(), isFalse);
});
});
}