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
+10 -1
View File
@@ -799,5 +799,14 @@
"error": {} "error": {}
} }
}, },
"localMusicFolderGenericName": "Selected folder" "localMusicFolderGenericName": "Selected folder",
"welcomeHeadline": "Your world, live",
"welcomeBody": "53,412 stations from 238 countries. Save your favorites, take them to the car, and wake up to them.",
"welcomeBullet1Title": "Per-station equalizer",
"welcomeBullet1Subtitle": "Plus a preset per output device",
"welcomeBullet2Title": "Android Auto",
"welcomeBullet2Subtitle": "Your favorites and local music in the car",
"welcomeBullet3Title": "Music alarms",
"welcomeBullet3Subtitle": "With gradual volume rise and vacation mode",
"welcomeCtaLabel": "Start listening"
} }
+10 -1
View File
@@ -758,5 +758,14 @@
"error": {} "error": {}
} }
}, },
"localMusicFolderGenericName": "Carpeta seleccionada" "localMusicFolderGenericName": "Carpeta seleccionada",
"welcomeHeadline": "Tu mundo, en directo",
"welcomeBody": "53.412 emisoras de 238 países. Guardá tus favoritas, llevalas al auto y despertate con ellas.",
"welcomeBullet1Title": "Ecualizador por emisora",
"welcomeBullet1Subtitle": "Y un preset por dispositivo de salida",
"welcomeBullet2Title": "Android Auto",
"welcomeBullet2Subtitle": "Tus favoritas y tu música local en el auto",
"welcomeBullet3Title": "Alarmas musicales",
"welcomeBullet3Subtitle": "Con subida progresiva y modo vacaciones",
"welcomeCtaLabel": "Empezar a escuchar"
} }
+54
View File
@@ -2869,6 +2869,60 @@ abstract class AppLocalizations {
/// In es, this message translates to: /// In es, this message translates to:
/// **'Carpeta seleccionada'** /// **'Carpeta seleccionada'**
String get localMusicFolderGenericName; String get localMusicFolderGenericName;
/// No description provided for @welcomeHeadline.
///
/// In es, this message translates to:
/// **'Tu mundo, en directo'**
String get welcomeHeadline;
/// No description provided for @welcomeBody.
///
/// In es, this message translates to:
/// **'53.412 emisoras de 238 países. Guardá tus favoritas, llevalas al auto y despertate con ellas.'**
String get welcomeBody;
/// No description provided for @welcomeBullet1Title.
///
/// In es, this message translates to:
/// **'Ecualizador por emisora'**
String get welcomeBullet1Title;
/// No description provided for @welcomeBullet1Subtitle.
///
/// In es, this message translates to:
/// **'Y un preset por dispositivo de salida'**
String get welcomeBullet1Subtitle;
/// No description provided for @welcomeBullet2Title.
///
/// In es, this message translates to:
/// **'Android Auto'**
String get welcomeBullet2Title;
/// No description provided for @welcomeBullet2Subtitle.
///
/// In es, this message translates to:
/// **'Tus favoritas y tu música local en el auto'**
String get welcomeBullet2Subtitle;
/// No description provided for @welcomeBullet3Title.
///
/// In es, this message translates to:
/// **'Alarmas musicales'**
String get welcomeBullet3Title;
/// No description provided for @welcomeBullet3Subtitle.
///
/// In es, this message translates to:
/// **'Con subida progresiva y modo vacaciones'**
String get welcomeBullet3Subtitle;
/// No description provided for @welcomeCtaLabel.
///
/// In es, this message translates to:
/// **'Empezar a escuchar'**
String get welcomeCtaLabel;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate
+30
View File
@@ -1580,4 +1580,34 @@ class AppLocalizationsAr extends AppLocalizations {
@override @override
String get localMusicFolderGenericName => 'مجلد محدد'; String get localMusicFolderGenericName => 'مجلد محدد';
@override
String get welcomeHeadline => 'Tu mundo, en directo';
@override
String get welcomeBody =>
'53.412 emisoras de 238 países. Guardá tus favoritas, llevalas al auto y despertate con ellas.';
@override
String get welcomeBullet1Title => 'Ecualizador por emisora';
@override
String get welcomeBullet1Subtitle => 'Y un preset por dispositivo de salida';
@override
String get welcomeBullet2Title => 'Android Auto';
@override
String get welcomeBullet2Subtitle =>
'Tus favoritas y tu música local en el auto';
@override
String get welcomeBullet3Title => 'Alarmas musicales';
@override
String get welcomeBullet3Subtitle =>
'Con subida progresiva y modo vacaciones';
@override
String get welcomeCtaLabel => 'Empezar a escuchar';
} }
+30
View File
@@ -1588,4 +1588,34 @@ class AppLocalizationsBn extends AppLocalizations {
@override @override
String get localMusicFolderGenericName => 'নির্বাচিত ফোল্ডার'; String get localMusicFolderGenericName => 'নির্বাচিত ফোল্ডার';
@override
String get welcomeHeadline => 'Tu mundo, en directo';
@override
String get welcomeBody =>
'53.412 emisoras de 238 países. Guardá tus favoritas, llevalas al auto y despertate con ellas.';
@override
String get welcomeBullet1Title => 'Ecualizador por emisora';
@override
String get welcomeBullet1Subtitle => 'Y un preset por dispositivo de salida';
@override
String get welcomeBullet2Title => 'Android Auto';
@override
String get welcomeBullet2Subtitle =>
'Tus favoritas y tu música local en el auto';
@override
String get welcomeBullet3Title => 'Alarmas musicales';
@override
String get welcomeBullet3Subtitle =>
'Con subida progresiva y modo vacaciones';
@override
String get welcomeCtaLabel => 'Empezar a escuchar';
} }
+30
View File
@@ -1599,4 +1599,34 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get localMusicFolderGenericName => 'Ausgewählter Ordner'; String get localMusicFolderGenericName => 'Ausgewählter Ordner';
@override
String get welcomeHeadline => 'Tu mundo, en directo';
@override
String get welcomeBody =>
'53.412 emisoras de 238 países. Guardá tus favoritas, llevalas al auto y despertate con ellas.';
@override
String get welcomeBullet1Title => 'Ecualizador por emisora';
@override
String get welcomeBullet1Subtitle => 'Y un preset por dispositivo de salida';
@override
String get welcomeBullet2Title => 'Android Auto';
@override
String get welcomeBullet2Subtitle =>
'Tus favoritas y tu música local en el auto';
@override
String get welcomeBullet3Title => 'Alarmas musicales';
@override
String get welcomeBullet3Subtitle =>
'Con subida progresiva y modo vacaciones';
@override
String get welcomeCtaLabel => 'Empezar a escuchar';
} }
+30
View File
@@ -1581,4 +1581,34 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get localMusicFolderGenericName => 'Selected folder'; String get localMusicFolderGenericName => 'Selected folder';
@override
String get welcomeHeadline => 'Your world, live';
@override
String get welcomeBody =>
'53,412 stations from 238 countries. Save your favorites, take them to the car, and wake up to them.';
@override
String get welcomeBullet1Title => 'Per-station equalizer';
@override
String get welcomeBullet1Subtitle => 'Plus a preset per output device';
@override
String get welcomeBullet2Title => 'Android Auto';
@override
String get welcomeBullet2Subtitle =>
'Your favorites and local music in the car';
@override
String get welcomeBullet3Title => 'Music alarms';
@override
String get welcomeBullet3Subtitle =>
'With gradual volume rise and vacation mode';
@override
String get welcomeCtaLabel => 'Start listening';
} }
+30
View File
@@ -1594,4 +1594,34 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get localMusicFolderGenericName => 'Carpeta seleccionada'; String get localMusicFolderGenericName => 'Carpeta seleccionada';
@override
String get welcomeHeadline => 'Tu mundo, en directo';
@override
String get welcomeBody =>
'53.412 emisoras de 238 países. Guardá tus favoritas, llevalas al auto y despertate con ellas.';
@override
String get welcomeBullet1Title => 'Ecualizador por emisora';
@override
String get welcomeBullet1Subtitle => 'Y un preset por dispositivo de salida';
@override
String get welcomeBullet2Title => 'Android Auto';
@override
String get welcomeBullet2Subtitle =>
'Tus favoritas y tu música local en el auto';
@override
String get welcomeBullet3Title => 'Alarmas musicales';
@override
String get welcomeBullet3Subtitle =>
'Con subida progresiva y modo vacaciones';
@override
String get welcomeCtaLabel => 'Empezar a escuchar';
} }
+30
View File
@@ -1603,4 +1603,34 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get localMusicFolderGenericName => 'Dossier sélectionné'; String get localMusicFolderGenericName => 'Dossier sélectionné';
@override
String get welcomeHeadline => 'Tu mundo, en directo';
@override
String get welcomeBody =>
'53.412 emisoras de 238 países. Guardá tus favoritas, llevalas al auto y despertate con ellas.';
@override
String get welcomeBullet1Title => 'Ecualizador por emisora';
@override
String get welcomeBullet1Subtitle => 'Y un preset por dispositivo de salida';
@override
String get welcomeBullet2Title => 'Android Auto';
@override
String get welcomeBullet2Subtitle =>
'Tus favoritas y tu música local en el auto';
@override
String get welcomeBullet3Title => 'Alarmas musicales';
@override
String get welcomeBullet3Subtitle =>
'Con subida progresiva y modo vacaciones';
@override
String get welcomeCtaLabel => 'Empezar a escuchar';
} }
+30
View File
@@ -1586,4 +1586,34 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get localMusicFolderGenericName => 'चयनित फ़ोल्डर'; String get localMusicFolderGenericName => 'चयनित फ़ोल्डर';
@override
String get welcomeHeadline => 'Tu mundo, en directo';
@override
String get welcomeBody =>
'53.412 emisoras de 238 países. Guardá tus favoritas, llevalas al auto y despertate con ellas.';
@override
String get welcomeBullet1Title => 'Ecualizador por emisora';
@override
String get welcomeBullet1Subtitle => 'Y un preset por dispositivo de salida';
@override
String get welcomeBullet2Title => 'Android Auto';
@override
String get welcomeBullet2Subtitle =>
'Tus favoritas y tu música local en el auto';
@override
String get welcomeBullet3Title => 'Alarmas musicales';
@override
String get welcomeBullet3Subtitle =>
'Con subida progresiva y modo vacaciones';
@override
String get welcomeCtaLabel => 'Empezar a escuchar';
} }
+30
View File
@@ -1592,4 +1592,34 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get localMusicFolderGenericName => 'Folder terpilih'; String get localMusicFolderGenericName => 'Folder terpilih';
@override
String get welcomeHeadline => 'Tu mundo, en directo';
@override
String get welcomeBody =>
'53.412 emisoras de 238 países. Guardá tus favoritas, llevalas al auto y despertate con ellas.';
@override
String get welcomeBullet1Title => 'Ecualizador por emisora';
@override
String get welcomeBullet1Subtitle => 'Y un preset por dispositivo de salida';
@override
String get welcomeBullet2Title => 'Android Auto';
@override
String get welcomeBullet2Subtitle =>
'Tus favoritas y tu música local en el auto';
@override
String get welcomeBullet3Title => 'Alarmas musicales';
@override
String get welcomeBullet3Subtitle =>
'Con subida progresiva y modo vacaciones';
@override
String get welcomeCtaLabel => 'Empezar a escuchar';
} }
+30
View File
@@ -1598,4 +1598,34 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get localMusicFolderGenericName => 'Cartella selezionata'; String get localMusicFolderGenericName => 'Cartella selezionata';
@override
String get welcomeHeadline => 'Tu mundo, en directo';
@override
String get welcomeBody =>
'53.412 emisoras de 238 países. Guardá tus favoritas, llevalas al auto y despertate con ellas.';
@override
String get welcomeBullet1Title => 'Ecualizador por emisora';
@override
String get welcomeBullet1Subtitle => 'Y un preset por dispositivo de salida';
@override
String get welcomeBullet2Title => 'Android Auto';
@override
String get welcomeBullet2Subtitle =>
'Tus favoritas y tu música local en el auto';
@override
String get welcomeBullet3Title => 'Alarmas musicales';
@override
String get welcomeBullet3Subtitle =>
'Con subida progresiva y modo vacaciones';
@override
String get welcomeCtaLabel => 'Empezar a escuchar';
} }
+30
View File
@@ -1545,4 +1545,34 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get localMusicFolderGenericName => '選択したフォルダー'; String get localMusicFolderGenericName => '選択したフォルダー';
@override
String get welcomeHeadline => 'Tu mundo, en directo';
@override
String get welcomeBody =>
'53.412 emisoras de 238 países. Guardá tus favoritas, llevalas al auto y despertate con ellas.';
@override
String get welcomeBullet1Title => 'Ecualizador por emisora';
@override
String get welcomeBullet1Subtitle => 'Y un preset por dispositivo de salida';
@override
String get welcomeBullet2Title => 'Android Auto';
@override
String get welcomeBullet2Subtitle =>
'Tus favoritas y tu música local en el auto';
@override
String get welcomeBullet3Title => 'Alarmas musicales';
@override
String get welcomeBullet3Subtitle =>
'Con subida progresiva y modo vacaciones';
@override
String get welcomeCtaLabel => 'Empezar a escuchar';
} }
+30
View File
@@ -1590,4 +1590,34 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get localMusicFolderGenericName => 'Pasta selecionada'; String get localMusicFolderGenericName => 'Pasta selecionada';
@override
String get welcomeHeadline => 'Tu mundo, en directo';
@override
String get welcomeBody =>
'53.412 emisoras de 238 países. Guardá tus favoritas, llevalas al auto y despertate con ellas.';
@override
String get welcomeBullet1Title => 'Ecualizador por emisora';
@override
String get welcomeBullet1Subtitle => 'Y un preset por dispositivo de salida';
@override
String get welcomeBullet2Title => 'Android Auto';
@override
String get welcomeBullet2Subtitle =>
'Tus favoritas y tu música local en el auto';
@override
String get welcomeBullet3Title => 'Alarmas musicales';
@override
String get welcomeBullet3Subtitle =>
'Con subida progresiva y modo vacaciones';
@override
String get welcomeCtaLabel => 'Empezar a escuchar';
} }
+30
View File
@@ -1594,4 +1594,34 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get localMusicFolderGenericName => 'Выбранная папка'; String get localMusicFolderGenericName => 'Выбранная папка';
@override
String get welcomeHeadline => 'Tu mundo, en directo';
@override
String get welcomeBody =>
'53.412 emisoras de 238 países. Guardá tus favoritas, llevalas al auto y despertate con ellas.';
@override
String get welcomeBullet1Title => 'Ecualizador por emisora';
@override
String get welcomeBullet1Subtitle => 'Y un preset por dispositivo de salida';
@override
String get welcomeBullet2Title => 'Android Auto';
@override
String get welcomeBullet2Subtitle =>
'Tus favoritas y tu música local en el auto';
@override
String get welcomeBullet3Title => 'Alarmas musicales';
@override
String get welcomeBullet3Subtitle =>
'Con subida progresiva y modo vacaciones';
@override
String get welcomeCtaLabel => 'Empezar a escuchar';
} }
+30
View File
@@ -1537,4 +1537,34 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get localMusicFolderGenericName => '已选文件夹'; String get localMusicFolderGenericName => '已选文件夹';
@override
String get welcomeHeadline => 'Tu mundo, en directo';
@override
String get welcomeBody =>
'53.412 emisoras de 238 países. Guardá tus favoritas, llevalas al auto y despertate con ellas.';
@override
String get welcomeBullet1Title => 'Ecualizador por emisora';
@override
String get welcomeBullet1Subtitle => 'Y un preset por dispositivo de salida';
@override
String get welcomeBullet2Title => 'Android Auto';
@override
String get welcomeBullet2Subtitle =>
'Tus favoritas y tu música local en el auto';
@override
String get welcomeBullet3Title => 'Alarmas musicales';
@override
String get welcomeBullet3Subtitle =>
'Con subida progresiva y modo vacaciones';
@override
String get welcomeCtaLabel => 'Empezar a escuchar';
} }
+169
View File
@@ -0,0 +1,169 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../estado/estado_navegacion.dart';
import '../l10n/gen/app_localizations.dart';
import '../tema/pluriwave_theme.dart';
/// WU17: first-run welcome surface (`onboarding-welcome` spec, mockup
/// screen 14) rebuilt WITHOUT its monetization content — no "PRO" pill, no
/// "14 días" trial line, no pricing card, and no secondary "free version"
/// link (binding no-monetization requirement). Content only: logo,
/// headline, body copy, exactly 3 feature bullets, and the single
/// "Empezar a escuchar" CTA.
///
/// 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.
class PantallaBienvenida extends StatelessWidget {
const PantallaBienvenida({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final t = context.pluriTokens;
final theme = Theme.of(context);
return Scaffold(
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 48, 24, 28),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Image.asset(
'assets/icons/pluriwave_app_mark.png',
width: 76,
height: 76,
errorBuilder:
(_, __, ___) => Icon(
Icons.graphic_eq_rounded,
size: 76,
color: t.electricMagenta,
),
),
const SizedBox(height: 22),
Text(
l10n.welcomeHeadline,
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.w800,
height: 1.05,
letterSpacing: -1.0,
),
),
const SizedBox(height: 12),
Text(
l10n.welcomeBody,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.72),
height: 1.5,
),
),
const SizedBox(height: 28),
FilaCaracteristicaBienvenida(
icon: Icons.equalizer_rounded,
iconColor: t.electricMagenta,
titulo: l10n.welcomeBullet1Title,
subtitulo: l10n.welcomeBullet1Subtitle,
),
const SizedBox(height: 16),
FilaCaracteristicaBienvenida(
icon: Icons.directions_car_rounded,
iconColor: t.liveGreen,
titulo: l10n.welcomeBullet2Title,
subtitulo: l10n.welcomeBullet2Subtitle,
),
const SizedBox(height: 16),
FilaCaracteristicaBienvenida(
icon: Icons.alarm_rounded,
iconColor: t.warmCoral,
titulo: l10n.welcomeBullet3Title,
subtitulo: l10n.welcomeBullet3Subtitle,
),
const SizedBox(height: 32),
SizedBox(
height: 58,
child: FilledButton(
onPressed: () => _empezar(context),
child: Text(
l10n.welcomeCtaLabel,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
),
),
),
),
],
),
),
),
);
}
void _empezar(BuildContext context) {
context.read<EstadoNavegacionRaiz>().irA(RaizPluriWave.escuchar);
Navigator.of(context).pop();
}
}
/// One icon-badge + title/subtitle row. Public (not `_FilaCaracteristica`)
/// so the structural "exactly 3 feature bullets" guard in
/// `pantalla_bienvenida_test.dart` can target it via `find.byType` — the
/// same reason WU4 made `FormularioEmisoraPersonalizada` public.
class FilaCaracteristicaBienvenida extends StatelessWidget {
const FilaCaracteristicaBienvenida({
super.key,
required this.icon,
required this.iconColor,
required this.titulo,
required this.subtitulo,
});
final IconData icon;
final Color iconColor;
final String titulo;
final String subtitulo;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.16),
borderRadius: BorderRadius.circular(11),
),
child: Icon(icon, size: 20, color: iconColor),
),
const SizedBox(width: 13),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
titulo,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w800,
),
),
Text(
subtitulo,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.58),
),
),
],
),
),
],
);
}
}
+43 -12
View File
@@ -56,7 +56,7 @@
| 15 | `feat(grabaciones): add recordings library screen` | 3b | ~~300-400~~**REALIZED: 1,767** (1,767+ / 0-, 22 files) | Medium | Monitor§ | | 15 | `feat(grabaciones): add recordings library screen` | 3b | ~~300-400~~**REALIZED: 1,767** (1,767+ / 0-, 22 files) | Medium | Monitor§ |
| 15b | `fix(grabaciones): wire the recordings library into Settings navigation` | 15 | 60-100 | Low | No | | 15b | `fix(grabaciones): wire the recordings library into Settings navigation` | 15 | 60-100 | Low | No |
| 16 | `feat(connectivity): restyle offline and reconnect banners` | 1 | 150-250 | Low | No | | 16 | `feat(connectivity): restyle offline and reconnect banners` | 1 | 150-250 | Low | No |
| 17 | `feat(bienvenida): add monetization-free welcome screen` | 1 | 150-200 | Low | No | | 17 | `feat(bienvenida): add monetization-free welcome screen` | 1 | ~~150-200~~**REALIZED: 361** (screen + test, 313 / ARB 22 / docs 48) — see the WU15-precedent note below | Low | No — same reasoning as WU15§ |
| 18 | `feat(i18n): add redesign strings and translate Escuchar rename to 11 locales` | all | ~0 eng. / 400-600 data | Medium (data volume, low logic risk) | No | | 18 | `feat(i18n): add redesign strings and translate Escuchar rename to 11 locales` | all | ~0 eng. / 400-600 data | Medium (data volume, low logic risk) | No |
\* **Re-derived after WU3a landed.** The original 500-700 figure was scaled pro-rata from WU3a's *estimate*, which \* **Re-derived after WU3a landed.** The original 500-700 figure was scaled pro-rata from WU3a's *estimate*, which
@@ -85,6 +85,11 @@ unlike WU3a/WU3b's move-only reuse), or (c) the new `servicio_grabacion_radio.da
group. Realized 1,767 changed lines / 22 files, all additions (no deletions — nothing pre-existing was touched group. Realized 1,767 changed lines / 22 files, all additions (no deletions — nothing pre-existing was touched
beyond the 3 new `ServicioGrabacionRadio`/`EstadoGrabacion` methods). Not recorded as `size:exception` since the beyond the 3 new `ServicioGrabacionRadio`/`EstadoGrabacion` methods). Not recorded as `size:exception` since the
commit is still a single, cleanly-scoped deliverable (one new screen, its one dependency, no split candidate). commit is still a single, cleanly-scoped deliverable (one new screen, its one dependency, no split candidate).
**WU17 hit the identical pattern**: 150-200 covered only the screen itself; realized 361 lines across the screen
(169) + its test (144) + 9 new ARB keys × 2 locales (22) + this docs update (48), PLUS 13 regenerated
`lib/l10n/gen/*.dart` files (~430 more lines, untouched by hand) for a raw `git diff --stat` total of 814. Same
ruling: not `size:exception` — one new, cleanly-scoped screen with no split candidate, all-additions except the ARB
files (which gain lines, not churn).
```text ```text
Decision needed before apply: No Decision needed before apply: No
@@ -1013,19 +1018,45 @@ real capability living in the header, not decorative chrome).
**Verify**: `flutter test test/pantallas/pantalla_bienvenida_test.dart && flutter analyze && dart format --set-exit-if-changed $(git diff --name-only --diff-filter=ACM HEAD -- '*.dart')` **Verify**: `flutter test test/pantallas/pantalla_bienvenida_test.dart && flutter analyze && dart format --set-exit-if-changed $(git diff --name-only --diff-filter=ACM HEAD -- '*.dart')`
**New tests**: `pantalla_bienvenida_test.dart` **New tests**: `pantalla_bienvenida_test.dart`
- [ ] 17.1 RED — the welcome screen renders as a full-screen route (not a modal `Dialog`) with a logo, headline, - [x] 17.1 RED — the welcome screen renders as a full-screen route (not a modal `Dialog`) with a logo, headline,
body copy, exactly 3 feature bullets, and exactly 1 primary CTA. body copy, exactly 3 feature bullets, and exactly 1 primary CTA. **Discovered at apply time**: a pre-existing,
- [ ] 17.2 RED — binding no-monetization guard: scan the rendered widget tree's text content and assert it contains UNRELATED `PluriOnboardingDialog` (a "what's new"/help-content modal loaded from markdown assets, shown from
`app.dart`'s launch flow) already exists — it is NOT the mockup's welcome screen and is not touched or
replaced by this WU.
- [x] 17.2 RED — binding no-monetization guard: scan the rendered widget tree's text content and assert it contains
none of: the substring "PRO", a currency amount, or a day-count trial phrase; no secondary "free version" none of: the substring "PRO", a currency amount, or a day-count trial phrase; no secondary "free version"
link. link.
- [ ] 17.3 RED — tapping "Empezar a escuchar" lands on the Escuchar tab (index 0) with the welcome route removed - [x] 17.3 RED — tapping "Empezar a escuchar" lands on the Escuchar tab (index 0) with the welcome route removed
from the back stack. from the back stack. Test starts `EstadoNavegacionRaiz` on a DIFFERENT tab first so a pass actually proves
- [ ] 17.4 GREEN — build `lib/pantallas/pantalla_bienvenida.dart` as a full-screen route; content only (logo / `irA()` ran, not just that escuchar was already the default.
headline / body / 3 bullets / 1 CTA) — no PRO pill, no trial duration, no price, no secondary link. - [x] 17.4 GREEN — built `lib/pantallas/pantalla_bienvenida.dart` as a full-screen route (bare `Scaffold`, no
- [ ] 17.5 GREEN — wire the CTA to dismiss-and-navigate to Escuchar with the route removed. `PluriPushScaffold` — this is an entry screen with no back affordance, not a second-level pushed screen);
- [ ] 17.6 REFACTOR — grep `lib/l10n/app_*.arb` for "PRO"/price/day-count trial strings introduced by this WU; content only (logo / headline / body / 3 bullets / 1 CTA) — no PRO pill, no trial duration, no price, no
confirm none exist. secondary link. Content is the mockup's screen 14 copy verbatim MINUS its entire monetization block (the "14
- [ ] 17.7 Verify — all 3 scenario tests green; grep scan clean. días PRO gratis / luego 2,99 €/año" pricing card and the "Seguir con la versión gratuita" link), Spanish
re-cast to the app's established voseo register (the raw mockup HTML uses "tú" form; the other ~750 lines of
`app_es.arb` consistently use voseo — e.g. "Elegí", "Guardá", "querés" — matched that instead of copying the
mockup's grammar verbatim) and "auto" instead of the mockup's "coche" (matching the one existing precedent at
`localMusicSectionDescription`). The 3 bullet icon colours reuse existing named tokens
(`electricMagenta`/`liveGreen`/`warmCoral`) that happen to be the exact hex values the mockup already used for
those 3 icons — no new raw `Color(0x...)` literal introduced. `SingleChildScrollView` used instead of a
`Column` + `Spacer()` (the exact overflow hazard WU14 already hit and fixed) since this screen carries
meaningfully more content than a typical card. 9 new ARB keys, en/es only (`welcomeHeadline`, `welcomeBody`,
`welcomeBullet{1,2,3}{Title,Subtitle}`, `welcomeCtaLabel`).
- [x] 17.5 GREEN — CTA wired to `context.read<EstadoNavegacionRaiz>().irA(RaizPluriWave.escuchar)` then
`Navigator.of(context).pop()`. **Scope note**: this WU's own verify command and task list only cover
`pantalla_bienvenida_test.dart` — wiring the screen into the REAL first-launch flow (i.e. `app.dart`/
`main.dart` deciding *when* to push it, analogous to `ServicioContenidoApp.debeMostrarInicio()`'s pattern for
`PluriOnboardingDialog`) is NOT part of WU17 as scoped and was deliberately not added here to avoid inventing
untested persistence/launch semantics. Flagged as a likely WU17b gap (same shape as WU15/WU15b).
- [x] 17.6 REFACTOR — whole-repo grep confirmed clean: `\bPRO\b` (word-boundary) has exactly one hit in all of
`lib/` — this WU's own doc comment describing the guard (never rendered). The unqualified substring "PRO"
also matches pre-existing, unrelated `"PROGRAMADOS"` (vacation-alarms section title, WU9) and
`pluri_premium_widgets.dart` (a pre-existing decorative-hero-card widget file name, "premium" as in visual
styling, not a paid tier) — both false positives for a naive substring check, neither a monetization string.
No day-count trial phrase, no €/$ amount, no "gratuita"/"gratis"/"free version" string anywhere in
`lib/l10n/app_*.arb` or `lib/`.
- [x] 17.7 Verify — all 3 scenario tests green; grep scan clean.
## WU18 — i18n batch (all 13 locales) ## WU18 — i18n batch (all 13 locales)
@@ -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);
},
);
}