4 Commits
Author SHA1 Message Date
FreeTLab 2959941485 fix(bienvenida): wire the welcome screen into the first-launch flow 2026-07-29 15:49:44 +02:00
FreeTLab f3d744aeed fix(ajustes): dispose sheet text controllers after the close animation
Pre-existing bug, reproduces identically before this branch's changes:
_editarGrupo (pantalla_ajustes_grupos_favoritos.dart) and
_editarTamanoMaximo (pantalla_ajustes_grabaciones.dart) each created a
TextEditingController, awaited showModalBottomSheet, then disposed the
controller immediately on resolve - racing the sheet's own close
animation, which still holds a bound TextField for a couple more
frames. Manifests as "A TextEditingController was used after being
disposed" plus a couple of cascading framework-internal symptoms.

Fix: extract each sheet's content into its own StatefulWidget
(_HojaEditarGrupo, _HojaTamanoMaximo) that owns the controller in its
own State. Flutter only calls State.dispose() once the widget is
actually removed from the tree, i.e. after the close animation
finishes, so there is no dispose-timing decision left for the caller
to get wrong.

_editarGrupo's own test previously suppressed the crash via a
FlutterError.onError override instead of fixing it; that suppression
is removed here. _editarTamanoMaximo had no coverage at all for this
interaction; added it. Strict TDD: confirmed both sites fail without
the fix (RED) before applying it (GREEN).
2026-07-29 15:22:15 +02:00
FreeTLab 9b415e73b0 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.
2026-07-29 15:04:44 +02:00
FreeTLab 862197ab48 feat(connectivity): restyle offline and reconnect banners
Tint the mini player's reconnecting/error sub-states with the
offlineAccent token (added in WU1, unused until now): the status
label, the reconnect spinner, and the error retry icon now read as
visually distinct "connectivity trouble" states instead of blending
into the ordinary loading/paused look. Plain buffering keeps the
default colour, confirmed by a dedicated regression test.

Verify-first gate (task 16.1): ControladorReconexion.intentos exists,
but ServicioAudio never surfaces it past a debug log line, and its
estadoStream only carries the EstadoReproduccion enum. Adding an
attempt-count label would require a getter/stream on
servicio_audio.dart, one of the files this change must keep at an
empty diff against main. Ship the restyle without the counter, per
the risk register's own fallback.

WU16.
2026-07-29 14:43:34 +02:00
29 changed files with 1431 additions and 164 deletions
+21 -4
View File
@@ -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<void> _mostrarFlujoPrimerLanzamiento() async {
if (mounted) {
await PantallaBienvenida.mostrarSiProcede(context);
}
await _mostrarOnboardingInicial();
}
Future<void> _mostrarOnboardingInicial() async {
await Future<void>.delayed(const Duration(milliseconds: 900));
if (!mounted || _alarmaSonandoActiva) return;
+10 -1
View File
@@ -799,5 +799,14 @@
"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": {}
}
},
"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:
/// **'Carpeta seleccionada'**
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
+30
View File
@@ -1580,4 +1580,34 @@ class AppLocalizationsAr extends AppLocalizations {
@override
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
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
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
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
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
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
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
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
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
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
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
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
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';
}
@@ -12,12 +12,6 @@ import '../../widgets/pluri_push_scaffold.dart';
/// verbatim from the former `_SeccionGrabaciones` in `pantalla_ajustes.dart`
/// — only the panel header's icon and title were removed (the pushed
/// screen's title now carries them); every method below is unchanged.
///
/// Known pre-existing bug, deliberately NOT fixed here (out of scope, moved
/// verbatim, tracked separately): [_editarTamanoMaximo] disposes its
/// [TextEditingController] immediately after `showModalBottomSheet` resolves,
/// racing the sheet's own close animation — the same shape of bug already
/// documented for `_editarGrupo` in `pantalla_ajustes_grupos_favoritos.dart`.
class PantallaAjustesGrabaciones extends StatelessWidget {
const PantallaAjustesGrabaciones({super.key});
@@ -92,53 +86,16 @@ class _CuerpoGrabaciones extends StatelessWidget {
Future<void> _editarTamanoMaximo(BuildContext context) async {
final estado = context.read<EstadoGrabacion>();
final l10n = AppLocalizations.of(context);
final actualMb = _bytesAMegabytes(estado.maxBytes);
final controller = TextEditingController(text: actualMb.toString());
final nuevoMb = await showModalBottomSheet<int>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (ctx) {
final bottom = MediaQuery.viewInsetsOf(ctx).bottom;
return Padding(
padding: EdgeInsets.fromLTRB(20, 0, 20, bottom + 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.recordingsMaxSizeDialogTitle,
style: Theme.of(ctx).textTheme.titleLarge,
),
const SizedBox(height: 16),
TextField(
controller: controller,
autofocus: true,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: l10n.recordingsMaxSizeMbLabel,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: () {
final value = int.tryParse(controller.text.trim());
if (value == null || value <= 0) return;
Navigator.of(ctx).pop(value);
},
icon: const Icon(Icons.save_rounded),
label: Text(l10n.saveQuickAccessButton),
),
],
),
);
},
builder: (ctx) => _HojaTamanoMaximo(actualMb: actualMb),
);
controller.dispose();
if (nuevoMb == null || !context.mounted) return;
final l10n = AppLocalizations.of(context);
await estado.cambiarMaxBytes(nuevoMb * 1024 * 1024);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
@@ -215,3 +172,74 @@ class _CuerpoGrabaciones extends StatelessWidget {
);
}
}
/// The "maximum recording size" bottom sheet's own content, as a
/// `StatefulWidget` so its `TextEditingController` is owned by the SHEET's
/// `State`, not by the caller's `async` function — same bugfix shape as
/// `_HojaEditarGrupo` in `pantalla_ajustes_grupos_favoritos.dart` (a
/// controller disposed immediately after `showModalBottomSheet` resolves
/// races the sheet's own close animation, which still holds a bound
/// `TextField` for a couple more frames). Flutter only calls
/// `State.dispose()` once this widget is actually removed from the tree,
/// i.e. after the close animation finishes.
class _HojaTamanoMaximo extends StatefulWidget {
const _HojaTamanoMaximo({required this.actualMb});
final int actualMb;
@override
State<_HojaTamanoMaximo> createState() => _HojaTamanoMaximoState();
}
class _HojaTamanoMaximoState extends State<_HojaTamanoMaximo> {
late final TextEditingController _controller = TextEditingController(
text: widget.actualMb.toString(),
);
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _guardar() {
final value = int.tryParse(_controller.text.trim());
if (value == null || value <= 0) return;
Navigator.of(context).pop(value);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final bottom = MediaQuery.viewInsetsOf(context).bottom;
return Padding(
padding: EdgeInsets.fromLTRB(20, 0, 20, bottom + 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.recordingsMaxSizeDialogTitle,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
TextField(
controller: _controller,
autofocus: true,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: l10n.recordingsMaxSizeMbLabel,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _guardar,
icon: const Icon(Icons.save_rounded),
label: Text(l10n.saveQuickAccessButton),
),
],
),
);
}
}
@@ -34,54 +34,14 @@ class _CuerpoGruposFavoritos extends StatelessWidget {
BuildContext context, [
GrupoFavoritos? grupo,
]) async {
final l10n = AppLocalizations.of(context);
final controller = TextEditingController(text: grupo?.nombre ?? '');
final nombre = await showModalBottomSheet<String>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (ctx) {
final bottom = MediaQuery.viewInsetsOf(ctx).bottom;
return Padding(
padding: EdgeInsets.fromLTRB(20, 0, 20, bottom + 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
grupo == null
? l10n.favoriteGroupsAdd
: l10n.favoriteGroupsEdit,
style: Theme.of(ctx).textTheme.titleLarge,
),
const SizedBox(height: 16),
TextField(
controller: controller,
autofocus: true,
maxLength: 28,
decoration: InputDecoration(
labelText: l10n.favoriteGroupsNameLabel,
helperText: l10n.favoriteGroupsNameTooLong,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 12),
FilledButton.icon(
icon: const Icon(Icons.save_rounded),
label: Text(AppLocalizations.of(ctx).saveQuickAccessButton),
onPressed: () {
final value = controller.text.trim();
if (value.isEmpty || value.length > 28) return;
Navigator.pop(ctx, value);
},
),
],
),
);
},
builder: (ctx) => _HojaEditarGrupo(grupo: grupo),
);
controller.dispose();
if (nombre == null || !context.mounted) return;
final l10n = AppLocalizations.of(context);
final estado = context.read<EstadoRadio>();
if (grupo == null) {
await estado.crearGrupoFavoritos(nombre);
@@ -172,3 +132,77 @@ class _CuerpoGruposFavoritos extends StatelessWidget {
);
}
}
/// The add/rename bottom sheet's own content, as a `StatefulWidget` so its
/// `TextEditingController` is owned by the SHEET's `State`, not by the
/// caller's `async` function (bugfix: a `TextEditingController` disposed
/// immediately after `showModalBottomSheet` resolves races the sheet's own
/// close animation, which still holds a `TextField` bound to that controller
/// for a couple more frames — "A TextEditingController was used after being
/// disposed"). Flutter only calls `State.dispose()` once this widget is
/// actually removed from the tree, i.e. after the close animation finishes,
/// so there is no dispose-timing decision left for the caller to get wrong.
class _HojaEditarGrupo extends StatefulWidget {
const _HojaEditarGrupo({required this.grupo});
final GrupoFavoritos? grupo;
@override
State<_HojaEditarGrupo> createState() => _HojaEditarGrupoState();
}
class _HojaEditarGrupoState extends State<_HojaEditarGrupo> {
late final TextEditingController _controller = TextEditingController(
text: widget.grupo?.nombre ?? '',
);
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _guardar() {
final value = _controller.text.trim();
if (value.isEmpty || value.length > 28) return;
Navigator.pop(context, value);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final bottom = MediaQuery.viewInsetsOf(context).bottom;
return Padding(
padding: EdgeInsets.fromLTRB(20, 0, 20, bottom + 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.grupo == null
? l10n.favoriteGroupsAdd
: l10n.favoriteGroupsEdit,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
TextField(
controller: _controller,
autofocus: true,
maxLength: 28,
decoration: InputDecoration(
labelText: l10n.favoriteGroupsNameLabel,
helperText: l10n.favoriteGroupsNameTooLong,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 12),
FilledButton.icon(
icon: const Icon(Icons.save_rounded),
label: Text(l10n.saveQuickAccessButton),
onPressed: _guardar,
),
],
),
);
}
}
+188
View File
@@ -0,0 +1,188 @@
import 'package:flutter/material.dart';
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
/// 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). 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<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
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),
),
),
],
),
),
],
);
}
}
+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);
}
}
+25 -6
View File
@@ -129,20 +129,31 @@ class _MiniReproductorState extends State<MiniReproductor> {
EstadoReproduccion.detenido;
final activo =
s == EstadoReproduccion.reproduciendo;
// WU16: reconectando/error are
// "connectivity trouble" states —
// tinted with offlineAccent so they
// read as visually distinct from
// ordinary loading/paused/stopped.
final conexionEnProblema =
s ==
EstadoReproduccion.reconectando ||
s == EstadoReproduccion.error;
return Text(
_labelEstado(l10n, s),
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(
color:
activo
conexionEnProblema
? t.offlineAccent
: activo
? t.warmCoral
: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.7),
fontWeight:
activo
(activo || conexionEnProblema)
? FontWeight.w600
: FontWeight.w400,
),
@@ -173,12 +184,20 @@ class _MiniReproductorState extends State<MiniReproductor> {
// cargando (spinner), never as the error/retry affordance.
if (s == EstadoReproduccion.cargando ||
s == EstadoReproduccion.reconectando) {
return const SizedBox(
// WU16: only the reconectando sub-state gets the offline
// accent — plain cargando (e.g. the very first play)
// keeps the default spinner colour, since it is not a
// connectivity problem.
final reconectando = s == EstadoReproduccion.reconectando;
return SizedBox(
width: 48,
height: 48,
child: Padding(
padding: EdgeInsets.all(12),
child: CircularProgressIndicator(strokeWidth: 2),
padding: const EdgeInsets.all(12),
child: CircularProgressIndicator(
strokeWidth: 2,
color: reconectando ? t.offlineAccent : null,
),
),
);
}
@@ -187,7 +206,7 @@ class _MiniReproductorState extends State<MiniReproductor> {
final emisoraActual = estado.emisoraActual;
return IconButton(
tooltip: l10n.retryAction,
icon: const Icon(Icons.refresh_rounded),
icon: Icon(Icons.refresh_rounded, color: t.offlineAccent),
onPressed:
emisoraActual != null
? () => estado.reproducir(emisoraActual)
+129 -23
View File
@@ -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.**
>
@@ -56,7 +61,7 @@
| 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 |
| 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 |
\* **Re-derived after WU3a landed.** The original 500-700 figure was scaled pro-rata from WU3a's *estimate*, which
@@ -85,6 +90,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
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).
**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
Decision needed before apply: No
@@ -971,7 +981,7 @@ real capability living in the header, not decorative chrome).
- [x] 15b.6 Verify — scoped suite green: 605/605 (up from 604), 2 skipped (unchanged, both pre-existing WU15
documented skips). `flutter analyze`: 1 issue, identical to baseline. Literal-encoding scan: zero hits.
## WU16 — Connectivity banner restyle (offline / reconnect)
**Commit**: `feat(connectivity): restyle offline and reconnect banners`
**Depends on**: WU1
@@ -979,16 +989,31 @@ real capability living in the header, not decorative chrome).
**Verify**: `flutter test test/widgets/reconnect_ui_test.dart test/servicios/servicio_audio_reconnect_test.dart && flutter analyze && dart format --set-exit-if-changed $(git diff --name-only --diff-filter=ACM HEAD -- '*.dart')`
**Modified tests**: `reconnect_ui_test.dart`. `servicio_audio_reconnect_test.dart` must pass unmodified.
- [ ] 16.1 Verify-first — run `servicio_audio_reconnect_test.dart` and inspect the reconnect controller: confirm
whether it tracks an attempt count.
- [ ] 16.2 RED — restyle test for the offline banner (visual only) and, **only if 16.1 confirms an attempt count
exists**, a test for the "Reconectando · intento N de M" label.
- [ ] 16.3 GREEN — restyle the offline banner to the new visual language.
- [ ] 16.4 GREEN — **conditionally**: wire the attempt-count label only if the controller exposes one; otherwise
ship the restyle without the counter — do not add new plumbing to invent one.
- [ ] 16.5 REFACTOR — confirm shimmer skeletons (`TarjetaEmisoraShimmer`) are unchanged.
- [ ] 16.6 Verify — `reconnect_ui_test.dart` updated and green; `servicio_audio_reconnect_test.dart` unmodified and
green.
- [x] 16.1 Verify-first — run `servicio_audio_reconnect_test.dart` and inspect the reconnect controller: confirm
whether it tracks an attempt count. **Result: it does NOT reach the UI.** `ControladorReconexion.intentos`
exists as a getter, but `ServicioAudio` (`lib/servicios/servicio_audio.dart`) only ever reads it inside a
`developer.log` debug line (line 413) — its public `estadoStream` maps every playback state to the
`EstadoReproduccion` enum (no attempt-count payload) and `_handler.reconectando` is a bare bool. Wiring a
count to the UI would require adding a getter/stream to `servicio_audio.dart`, which is one of the four
files this change must keep at an **empty git diff vs `main`** — structurally blocked, not just untested.
Shipping without the counter per 16.4, exactly as the risk register anticipated.
- [x] 16.2 RED — 3 new `testWidgets` cases added to `reconnect_ui_test.dart` (no attempt-count test, per 16.1):
reconnecting tints the spinner + status label with `offlineAccent`; plain `cargando` keeps the default
spinner colour (regression guard proving the tint is reconnect-specific, not blanket-loading); error tints
the retry icon + status label with `offlineAccent`. Confirmed RED: 2 of the 3 failed (`Actual: <null>`)
against the pre-restyle widget.
- [x] 16.3 GREEN — `lib/widgets/mini_reproductor.dart`: the status-label `Text` and the reconnect spinner /
error retry icon now read `context.pluriTokens.offlineAccent` (WU1's previously-unused token, whose own
doc comment already named it for this WU) whenever the stream reports `reconectando` or `error`. Plain
`cargando` explicitly keeps `color: null` (the theme default) — verified by 16.2's regression test.
- [x] 16.4 GREEN — conditional step confirmed moot: no attempt-count label added, no new plumbing introduced.
- [x] 16.5 REFACTOR — `TarjetaEmisoraShimmer` (`lib/widgets/tarjeta_emisora.dart`, consumed by
`pantalla_buscar.dart`) is untouched by this commit — confirmed via `git diff --stat`, zero lines.
- [x] 16.6 Verify — scoped suite green: 16/16 (5 `reconnect_ui_test.dart` + 8 `servicio_audio_reconnect_test.dart`
[byte-identical, unmodified] + 3 `mini_reproductor_configurar_test.dart` re-run as an adjacent-file
regression check). `flutter analyze`: 1 issue, identical to baseline. Scoped `dart format`: reformatted the
hand-written production file once (whitespace only), stable on re-run. Literal-encoding scan: zero hits on
the 2 touched files.
## WU17 — Welcome / onboarding screen
@@ -998,19 +1023,100 @@ 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')`
**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,
body copy, exactly 3 feature bullets, and exactly 1 primary CTA.
- [ ] 17.2 RED — binding no-monetization guard: scan the rendered widget tree's text content and assert it contains
- [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. **Discovered at apply time**: a pre-existing,
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"
link.
- [ ] 17.3 RED — tapping "Empezar a escuchar" lands on the Escuchar tab (index 0) with the welcome route removed
from the back stack.
- [ ] 17.4 GREEN — build `lib/pantallas/pantalla_bienvenida.dart` as a full-screen route; content only (logo /
headline / body / 3 bullets / 1 CTA) — no PRO pill, no trial duration, no price, no secondary link.
- [ ] 17.5 GREEN — wire the CTA to dismiss-and-navigate to Escuchar with the route removed.
- [ ] 17.6 REFACTOR — grep `lib/l10n/app_*.arb` for "PRO"/price/day-count trial strings introduced by this WU;
confirm none exist.
- [ ] 17.7 Verify — all 3 scenario tests green; grep scan clean.
- [x] 17.3 RED — tapping "Empezar a escuchar" lands on the Escuchar tab (index 0) with the welcome route removed
from the back stack. Test starts `EstadoNavegacionRaiz` on a DIFFERENT tab first so a pass actually proves
`irA()` ran, not just that escuchar was already the default.
- [x] 17.4 GREEN — built `lib/pantallas/pantalla_bienvenida.dart` as a full-screen route (bare `Scaffold`, no
`PluriPushScaffold` — this is an entry screen with no back affordance, not a second-level pushed screen);
content only (logo / headline / body / 3 bullets / 1 CTA) — no PRO pill, no trial duration, no price, no
secondary link. Content is the mockup's screen 14 copy verbatim MINUS its entire monetization block (the "14
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.
## 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)
@@ -28,14 +28,6 @@ void _suppressListTileInkAssertion() {
/// WU3b task 3b.1: the GRABACIONES Y MÚSICA detail screen for "Grabaciones"
/// renders inside a [PluriPushScaffold] and its moved controls still respond
/// exactly as they did inside the old `_SeccionGrabaciones`.
///
/// This file deliberately does NOT interact with "Maximum recording size"
/// (`_editarTamanoMaximo`): that control has a pre-existing, out-of-scope
/// controller-dispose race (documented in `pantalla_ajustes_grabaciones.dart`
/// and, for the analogous `_editarGrupo` case, in
/// `pantalla_ajustes_grupos_favoritos_test.dart`) that this move does not
/// fix. "Restore default path" is exercised instead — a real, moved
/// capability with no such hazard.
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
@@ -100,4 +92,37 @@ void main() {
await tester.pump(const Duration(seconds: 5));
await tester.pumpAndSettle();
});
testWidgets(
'moved control still responds: editing the maximum recording size '
'persists it',
(tester) async {
_suppressListTileInkAssertion();
final estado = EstadoGrabacion(
servicio: FakeServicioGrabacionRadioInactiva(),
);
addTearDown(estado.dispose);
expect(estado.maxBytes, 500 * 1024 * 1024, reason: 'default, not 250');
await tester.pumpWidget(buildScreen(estado));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
await tester.tap(find.text('Maximum recording size'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), '250');
await tester.pumpAndSettle();
await tester.tap(find.text('Save quick access'));
await tester.pumpAndSettle();
expect(estado.maxBytes, 250 * 1024 * 1024);
expect(find.text('Recording limit updated to 250 MB'), findsOneWidget);
// SnackBar's own dismiss Timer is not frame-scheduled — let it resolve
// before teardown (WU3a batch discovery) instead of leaving a pending
// Timer behind.
await tester.pump(const Duration(seconds: 5));
await tester.pumpAndSettle();
},
);
}
@@ -33,39 +33,6 @@ void _suppressListTileInkAssertion() {
addTearDown(() => FlutterError.onError = original);
}
/// Pre-existing bug, out of scope for this move-only WU: `_editarGrupo`
/// (copied verbatim from the old `_SeccionGruposFavoritos`) disposes its
/// `TextEditingController` immediately after `showModalBottomSheet` resolves,
/// racing the sheet's own close animation, which still holds a `TextField`
/// bound to that controller for a couple more frames. It does not stop
/// `crearGrupoFavoritos` from running correctly, and reproduces identically
/// against the pre-WU3a combined screen (this widget's body is unmodified).
/// Fixing the dispose timing would be a logic edit, which this WU's
/// verbatim-move contract forbids; flagged for a future fix instead.
///
/// The one race produces a cascade of framework-internal symptoms while the
/// sheet's close animation and the disposed controller fight over the same
/// frame (an overlay `_dependents.isEmpty` assertion, and a transient
/// RenderFlex overflow against this file's bottom-sheet Column). All are
/// suppressed together as one documented, narrowly-scoped exception.
void _suppressDisposedControllerCascade() {
final original = FlutterError.onError;
FlutterError.onError = (details) {
final message = details.exceptionAsString();
final full = details.toString();
final isKnownCascade =
message.contains(
'A TextEditingController was used after being disposed',
) ||
message.contains("'_dependents.isEmpty': is not true") ||
(message.contains('RenderFlex overflowed') &&
full.contains('pantalla_ajustes_grupos_favoritos.dart'));
if (isKnownCascade) return;
original?.call(details);
};
addTearDown(() => FlutterError.onError = original);
}
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
@@ -124,7 +91,6 @@ void main() {
tester,
) async {
_suppressListTileInkAssertion();
_suppressDisposedControllerCascade();
final estado = await crearEstado();
addTearDown(estado.dispose);
@@ -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,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);
},
);
}
@@ -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);
});
});
}
+117
View File
@@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
import 'package:pluriwave/tema/pluriwave_tokens.dart';
import 'package:pluriwave/widgets/mini_reproductor.dart';
import 'package:provider/provider.dart';
@@ -93,4 +94,120 @@ void main() {
expect(find.byType(AlertDialog), findsNothing);
expect(find.byIcon(Icons.refresh_rounded), findsOneWidget);
});
// WU16: the offline/reconnect banner restyle. `offlineAccent` (added in
// WU1, previously unused by any screen) tints the connectivity-trouble
// sub-states so they read as visually distinct from ordinary loading.
testWidgets(
'reconnecting state tints the spinner and status label with the offline accent',
(tester) async {
final audio = FakeServicioAudio();
final estado = _estadoRadio(audio);
addTearDown(estado.dispose);
await audio.reproducir(emisoraDemo(uuid: 'r1', nombre: 'Radio Uno'));
await tester.pumpWidget(
ChangeNotifierProvider<EstadoRadio>.value(
value: estado,
child: MaterialApp(
locale: const Locale('es'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: MiniReproductor()),
),
),
);
await tester.pumpAndSettle();
audio.emitirEstado(EstadoReproduccion.reconectando);
await tester.pump();
await tester.pump();
expect(
tester
.widget<CircularProgressIndicator>(
find.byType(CircularProgressIndicator),
)
.color,
PluriWaveTokens.dark.offlineAccent,
);
expect(
tester.widget<Text>(find.text('Reconectando...')).style?.color,
PluriWaveTokens.dark.offlineAccent,
);
},
);
testWidgets(
'plain buffering keeps the default spinner colour, not the offline accent',
(tester) async {
final audio = FakeServicioAudio();
final estado = _estadoRadio(audio);
addTearDown(estado.dispose);
await audio.reproducir(emisoraDemo(uuid: 'r1', nombre: 'Radio Uno'));
await tester.pumpWidget(
ChangeNotifierProvider<EstadoRadio>.value(
value: estado,
child: MaterialApp(
locale: const Locale('es'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: MiniReproductor()),
),
),
);
await tester.pumpAndSettle();
audio.emitirEstado(EstadoReproduccion.cargando);
await tester.pump();
await tester.pump();
expect(
tester
.widget<CircularProgressIndicator>(
find.byType(CircularProgressIndicator),
)
.color,
isNull,
reason: 'cargando is not a connectivity problem, keep the default',
);
},
);
testWidgets(
'error state tints the retry icon and status label with the offline accent',
(tester) async {
final audio = FakeServicioAudio();
final estado = _estadoRadio(audio);
addTearDown(estado.dispose);
await audio.reproducir(emisoraDemo(uuid: 'r1', nombre: 'Radio Uno'));
await tester.pumpWidget(
ChangeNotifierProvider<EstadoRadio>.value(
value: estado,
child: MaterialApp(
locale: const Locale('es'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: MiniReproductor()),
),
),
);
await tester.pumpAndSettle();
audio.emitirEstado(EstadoReproduccion.error);
await tester.pump();
await tester.pump();
expect(
tester.widget<Icon>(find.byIcon(Icons.refresh_rounded)).color,
PluriWaveTokens.dark.offlineAccent,
);
expect(
tester.widget<Text>(find.text('Error de conexión')).style?.color,
PluriWaveTokens.dark.offlineAccent,
);
},
);
}