feat(bienvenida): add monetization-free welcome screen

Build the first-run welcome surface from mockup screen 14, stripped of
its entire monetization block: no PRO pill, no "14 dias PRO gratis"
trial line, no pricing card, no secondary "free version" link. Ships
only logo, headline, body copy, exactly 3 feature bullets, and the
single "Empezar a escuchar" CTA, as a full-screen route (not a modal).

Spanish copy is re-cast from the mockup's "tu" form to the app's
established voseo register (matching ~750 existing app_es.arb lines),
using "auto" instead of "coche" per the one existing precedent. The 3
bullet icons reuse existing tokens (electricMagenta/liveGreen/
warmCoral) that already match the mockup's own hex values for them.

CTA switches to the Escuchar tab and pops the route. Wiring this
screen into the real first-launch flow (main.dart/app.dart) is left
for a follow-up unit, same shape as WU15/WU15b - this WU only covers
the isolated, tested screen per its own task list and verify command.

WU17.
This commit is contained in:
2026-07-29 15:04:44 +02:00
parent 862197ab48
commit 9b415e73b0
19 changed files with 820 additions and 14 deletions
@@ -0,0 +1,144 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_navegacion.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/pantallas/pantalla_bienvenida.dart';
import 'package:provider/provider.dart';
/// WU17: `onboarding-welcome` spec — full-screen route (not a modal),
/// content only (logo/headline/body/3 bullets/1 CTA), binding
/// no-monetization guard, and CTA dismisses to Escuchar with the route
/// popped.
Widget _app({required EstadoNavegacionRaiz navegacion}) {
return ChangeNotifierProvider<EstadoNavegacionRaiz>.value(
value: navegacion,
child: const MaterialApp(
locale: Locale('es'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: PantallaBienvenida(),
),
);
}
void main() {
testWidgets(
'renders as a full-screen route with a logo, headline, body copy, '
'exactly 3 feature bullets, and exactly one primary CTA',
(tester) async {
final navegacion = EstadoNavegacionRaiz();
addTearDown(navegacion.dispose);
await tester.pumpWidget(_app(navegacion: navegacion));
await tester.pumpAndSettle();
// A full-screen route, never a modal dialog.
expect(find.byType(Dialog), findsNothing);
expect(find.byType(AlertDialog), findsNothing);
expect(find.byType(Scaffold), findsOneWidget);
expect(find.byType(Image), findsOneWidget, reason: 'the logo mark');
expect(find.text('Tu mundo, en directo'), findsOneWidget);
expect(
find.textContaining('53.412 emisoras de 238 países'),
findsOneWidget,
);
expect(
find.byType(FilaCaracteristicaBienvenida),
findsNWidgets(3),
reason: 'exactly 3 feature bullets',
);
expect(
find.byType(FilledButton),
findsOneWidget,
reason: 'exactly 1 primary CTA',
);
expect(find.text('Empezar a escuchar'), findsOneWidget);
},
);
testWidgets('rendered content contains no monetization strings', (
tester,
) async {
final navegacion = EstadoNavegacionRaiz();
addTearDown(navegacion.dispose);
await tester.pumpWidget(_app(navegacion: navegacion));
await tester.pumpAndSettle();
final textoCompleto = tester
.widgetList<Text>(find.byType(Text))
.map((widget) => widget.data ?? '')
.join('\n');
expect(textoCompleto.contains('PRO'), isFalse);
expect(textoCompleto.contains(''), isFalse);
expect(textoCompleto.contains(r'$'), isFalse);
expect(
RegExp(r'\d+\s*d[ií]as?\b', caseSensitive: false).hasMatch(
textoCompleto,
),
isFalse,
reason: 'no day-count trial phrase (e.g. "14 días")',
);
expect(
find.textContaining('gratuita', findRichText: true),
findsNothing,
reason: 'no secondary "free version" link',
);
expect(
find.textContaining('gratis', findRichText: true),
findsNothing,
reason: 'no secondary "free version" link',
);
// Only one tappable action on the whole screen: the primary CTA.
expect(find.byType(TextButton), findsNothing);
expect(find.byType(FilledButton), findsOneWidget);
});
testWidgets(
'tapping the primary CTA switches to Escuchar and pops the welcome route',
(tester) async {
// Start on a DIFFERENT tab so a pass actually proves irA() ran,
// rather than coasting on EstadoNavegacionRaiz's own escuchar default.
final navegacion = EstadoNavegacionRaiz()
..irA(RaizPluriWave.ajustes);
addTearDown(navegacion.dispose);
final navigatorKey = GlobalKey<NavigatorState>();
await tester.pumpWidget(
ChangeNotifierProvider<EstadoNavegacionRaiz>.value(
value: navegacion,
child: MaterialApp(
navigatorKey: navigatorKey,
locale: const Locale('es'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: Center(child: Text('home stand-in'))),
),
),
);
unawaited(
navigatorKey.currentState!.push(
MaterialPageRoute<void>(builder: (_) => const PantallaBienvenida()),
),
);
await tester.pumpAndSettle();
expect(find.byType(PantallaBienvenida), findsOneWidget);
await tester.tap(find.text('Empezar a escuchar'));
await tester.pumpAndSettle();
expect(navegacion.actual, RaizPluriWave.escuchar);
expect(
find.byType(PantallaBienvenida),
findsNothing,
reason: 'the welcome route was removed from the back stack',
);
expect(find.text('home stand-in'), findsOneWidget);
},
);
}