diff --git a/lib/app.dart b/lib/app.dart index fb0f2dd..dd36bb1 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -14,6 +14,7 @@ import 'l10n/gen/app_localizations.dart'; import 'modelos/alarma_musical.dart'; import 'pantallas/pantalla_alarmas.dart'; import 'pantallas/pantalla_alarma_sonando.dart'; +import 'pantallas/pantalla_bienvenida.dart'; import 'pantallas/pantalla_inicio.dart'; import 'pantallas/pantalla_buscar.dart'; import 'pantallas/pantalla_favoritos.dart'; @@ -108,7 +109,10 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> EstadoRadio? _estadoSuscrito; bool _alarmaInicialProcesada = 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; Locale? _localeAlarmasConfigurado; @@ -191,9 +195,9 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> _alarmaInicialProcesada = true; unawaited(_procesarAlarmaInicial(alarmas)); } - if (!_onboardingInicialSolicitado) { - _onboardingInicialSolicitado = true; - unawaited(_mostrarOnboardingInicial()); + if (!_flujoPrimerLanzamientoSolicitado) { + _flujoPrimerLanzamientoSolicitado = true; + 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 _mostrarFlujoPrimerLanzamiento() async { + if (mounted) { + await PantallaBienvenida.mostrarSiProcede(context); + } + await _mostrarOnboardingInicial(); + } + Future _mostrarOnboardingInicial() async { await Future.delayed(const Duration(milliseconds: 900)); if (!mounted || _alarmaSonandoActiva) return; diff --git a/lib/pantallas/pantalla_bienvenida.dart b/lib/pantallas/pantalla_bienvenida.dart index 76db7b6..c95ff21 100644 --- a/lib/pantallas/pantalla_bienvenida.dart +++ b/lib/pantallas/pantalla_bienvenida.dart @@ -3,6 +3,7 @@ import 'package:provider/provider.dart'; import '../estado/estado_navegacion.dart'; import '../l10n/gen/app_localizations.dart'; +import '../servicios/servicio_bienvenida.dart'; import '../tema/pluriwave_theme.dart'; /// 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 /// the pre-existing `PluriOnboardingDialog` (an unrelated "what's new" -/// help-content modal already shown from `app.dart`'s launch flow). This -/// work unit's own task list and verify command scope to this screen in -/// isolation; wiring it into the real first-launch flow (i.e. deciding -/// when `app.dart`/`main.dart` should push it) is not part of WU17. +/// help-content modal already shown from `app.dart`'s launch flow). Both +/// surfaces coexist: [mostrarSiProcede] (WU17b) is what actually wires this +/// screen into the genuine first-launch flow, called from `app.dart` BEFORE +/// `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 { 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 mostrarSiProcede(BuildContext context) async { + if (!await _servicio.debeMostrarBienvenida()) return; + if (!context.mounted) return; + await Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const PantallaBienvenida())); + await _servicio.marcarBienvenidaVista(); + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); diff --git a/lib/servicios/servicio_bienvenida.dart b/lib/servicios/servicio_bienvenida.dart new file mode 100644 index 0000000..92f35ad --- /dev/null +++ b/lib/servicios/servicio_bienvenida.dart @@ -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 _resolverPrefs() async => + _prefs ?? SharedPreferences.getInstance(); + + Future debeMostrarBienvenida() async { + final prefs = await _resolverPrefs(); + return !(prefs.getBool(_keyBienvenidaVista) ?? false); + } + + Future marcarBienvenidaVista() async { + final prefs = await _resolverPrefs(); + await prefs.setBool(_keyBienvenidaVista, true); + } +} diff --git a/openspec/changes/rediseno-funcional/tasks.md b/openspec/changes/rediseno-funcional/tasks.md index feabc10..23652cd 100644 --- a/openspec/changes/rediseno-funcional/tasks.md +++ b/openspec/changes/rediseno-funcional/tasks.md @@ -16,6 +16,11 @@ > **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 > 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. > **`flutter build` is never run.** > @@ -1058,6 +1063,61 @@ real capability living in the header, not decorative chrome). `lib/l10n/app_*.arb` or `lib/`. - [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) **Commit**: `feat(i18n): add redesign strings and translate Escuchar rename to 11 locales` diff --git a/test/pantallas/pantalla_bienvenida_primer_lanzamiento_test.dart b/test/pantallas/pantalla_bienvenida_primer_lanzamiento_test.dart new file mode 100644 index 0000000..750c6d2 --- /dev/null +++ b/test/pantallas/pantalla_bienvenida_primer_lanzamiento_test.dart @@ -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 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(); + + 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(); + + 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(); + + 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); + }); +} diff --git a/test/servicios/servicio_bienvenida_test.dart b/test/servicios/servicio_bienvenida_test.dart new file mode 100644 index 0000000..065bd1c --- /dev/null +++ b/test/servicios/servicio_bienvenida_test.dart @@ -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); + }); + }); +}