Compare commits
4
Commits
dc21732027
...
2959941485
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2959941485 | ||
|
|
f3d744aeed | ||
|
|
9b415e73b0 | ||
|
|
862197ab48 |
+21
-4
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user