Compare commits
5
Commits
48ece948ff
...
3a803ce2bf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a803ce2bf | ||
|
|
504a13641f | ||
|
|
ebdde7df01 | ||
|
|
589fc54580 | ||
|
|
c1903623be |
+5
-1
@@ -254,7 +254,11 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal>
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const MiniReproductor(),
|
||||
// ADR-7(b): hidden on Escuchar (index 0) only — its embedded
|
||||
// hero already shows the same station. Stays mounted (visible:
|
||||
// false renders SizedBox.shrink(), not tree removal) so its
|
||||
// didChangeDependencies side effect (S3-R3) keeps running.
|
||||
MiniReproductor(visible: indice != RaizPluriWave.escuchar.index),
|
||||
PluriBottomNavigation(
|
||||
items: _navItems(l10n),
|
||||
selectedIndex: indice,
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../l10n/gen/app_localizations.dart';
|
||||
import '../modelos/archivo_grabacion.dart';
|
||||
import '../modelos/emisora.dart';
|
||||
import '../servicios/servicio_grabacion_radio.dart';
|
||||
|
||||
@@ -101,6 +102,25 @@ class EstadoGrabacion extends ChangeNotifier {
|
||||
|
||||
Future<String> directorioEfectivo() => servicio.directorioEfectivo();
|
||||
|
||||
/// WU15, recordings-library: browsable listing of recording files
|
||||
/// already on disk. Thin delegate — no additional logic.
|
||||
Future<List<ArchivoGrabacion>> listarGrabaciones() =>
|
||||
servicio.listarGrabaciones();
|
||||
|
||||
/// WU15: deletes [ruta] and notifies listeners so the library screen's
|
||||
/// row disappears.
|
||||
Future<void> eliminarGrabacion(String ruta) async {
|
||||
await servicio.eliminarGrabacion(ruta);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// WU15: renames the recording at [ruta] to [nuevoNombre] and notifies
|
||||
/// listeners so the library screen reflects the new name.
|
||||
Future<void> renombrarGrabacion(String ruta, String nuevoNombre) async {
|
||||
await servicio.renombrarGrabacion(ruta, nuevoNombre);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<bool> abrirDirectorio() async {
|
||||
final ruta = await directorioEfectivo();
|
||||
await Directory(ruta).create(recursive: true);
|
||||
|
||||
@@ -164,6 +164,7 @@ class EstadoRadio extends ChangeNotifier {
|
||||
final _memoPopulares = MemoLista<Emisora>();
|
||||
final _memoTendencias = MemoLista<Emisora>();
|
||||
final _memoFavoritos = MemoLista<Emisora>();
|
||||
final _memoFavoritosManual = MemoLista<Emisora>();
|
||||
final _memoGrupos = MemoLista<GrupoFavoritos>();
|
||||
final _memoCustom = MemoLista<Emisora>();
|
||||
final _memoInicio = MemoLista<Emisora>();
|
||||
@@ -200,6 +201,17 @@ class EstadoRadio extends ChangeNotifier {
|
||||
_listaFavoritos,
|
||||
_ordenListas,
|
||||
], () => ordenarEmisoras(_listaFavoritos, _ordenListas));
|
||||
|
||||
/// WU4, `favorites-organization` spec: Favoritos' own manual order —
|
||||
/// unlike [listaFavoritos], this is NOT re-sorted by the global
|
||||
/// [ordenListas] setting on every read. It reflects exactly the order
|
||||
/// stored via the `orden` column (`ServicioFavoritos.obtenerTodos()`),
|
||||
/// which [reordenarFavorito] and [ordenarFavoritos] mutate. Other
|
||||
/// consumers of `listaFavoritos` (Android Auto, the Escuchar grid) are
|
||||
/// unaffected by drag-reordering Favoritos.
|
||||
List<Emisora> get listaFavoritosManual => _memoFavoritosManual.obtener([
|
||||
_listaFavoritos,
|
||||
], () => List<Emisora>.unmodifiable(_listaFavoritos));
|
||||
List<GrupoFavoritos> get gruposFavoritos => _memoGrupos.obtener([
|
||||
_gruposFavoritos,
|
||||
], () => List<GrupoFavoritos>.unmodifiable(_gruposFavoritos));
|
||||
@@ -371,6 +383,30 @@ class EstadoRadio extends ChangeNotifier {
|
||||
await cargarFavoritos();
|
||||
}
|
||||
|
||||
/// WU4: persists a single drag-to-reorder move within [listaFavoritosManual].
|
||||
/// [nuevoIndice] is the absolute target position among ALL favorites (not
|
||||
/// scoped to any chip filter) — the screen translates a filtered-list drag
|
||||
/// into this global index before calling this method.
|
||||
Future<void> reordenarFavorito(String uuid, int nuevoIndice) async {
|
||||
await favoritos.reordenar(uuid, nuevoIndice);
|
||||
await cargarFavoritos();
|
||||
}
|
||||
|
||||
/// WU4: the Favoritos `swap_vert` sort action. Reuses the existing
|
||||
/// [OrdenEmisoras] criteria and [ordenarEmisoras] function — "It MUST NOT
|
||||
/// introduce a sort criterion with no backing implementation"
|
||||
/// (favorites-organization spec). Unlike [ordenListas] (a GLOBAL setting
|
||||
/// affecting favorites/searches/nearby/quick-lists), this sorts ONLY the
|
||||
/// favorites list once and PERSISTS the result as the new manual order —
|
||||
/// consistent with [reordenarFavorito]'s "the order MUST persist" contract.
|
||||
Future<void> ordenarFavoritos(OrdenEmisoras criterio) async {
|
||||
final ordenados = ordenarEmisoras(_listaFavoritos, criterio);
|
||||
for (var i = 0; i < ordenados.length; i++) {
|
||||
await favoritos.reordenar(ordenados[i].uuid, i);
|
||||
}
|
||||
await cargarFavoritos();
|
||||
}
|
||||
|
||||
Future<void> cambiarEmisoraPreferida(Emisora? emisora) async {
|
||||
_emisoraPreferidaUuid = emisora?.uuid;
|
||||
final prefs = await _resolverPrefs();
|
||||
|
||||
@@ -55,6 +55,9 @@
|
||||
"settingsSubtitle": "Fine-grained sound control, backups, and custom stations.",
|
||||
"settingsGroupAudioTitle": "AUDIO",
|
||||
"settingsGroupStationsTitle": "STATIONS",
|
||||
"settingsGroupRecordingsTitle": "RECORDINGS & MUSIC",
|
||||
"settingsGroupApplicationTitle": "APPLICATION",
|
||||
"infoSectionTitle": "Info",
|
||||
"languageSectionTitle": "Language",
|
||||
"languageSectionDescription": "Choose how the app language is displayed.",
|
||||
"languageSystemDefault": "System",
|
||||
@@ -206,6 +209,29 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"recordingsLibraryTitle": "My recordings",
|
||||
"recordingsLibraryStorageCaption": "{usedMb} MB of {totalMb} MB used",
|
||||
"@recordingsLibraryStorageCaption": {
|
||||
"placeholders": {
|
||||
"usedMb": {
|
||||
"type": "int"
|
||||
},
|
||||
"totalMb": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"recordingsLibraryEmptyTitle": "No recordings yet",
|
||||
"recordingsLibraryEmptySubtitle": "Recordings you save will appear here.",
|
||||
"recordingActionRename": "Rename",
|
||||
"recordingActionShare": "Share",
|
||||
"recordingActionDelete": "Delete",
|
||||
"recordingRenameDialogTitle": "Rename recording",
|
||||
"recordingRenameLabel": "Name",
|
||||
"recordingRenameEmptyError": "Enter a name",
|
||||
"recordingDeleteConfirmTitle": "Delete recording?",
|
||||
"recordingDeleteConfirmMessage": "This can't be undone.",
|
||||
"recordingsLibrarySettingsTooltip": "Recording settings",
|
||||
"stationOrderTitle": "Station order",
|
||||
"stationOrderByName": "By name",
|
||||
"stationOrderByQuality": "By quality",
|
||||
@@ -256,6 +282,18 @@
|
||||
"stationName": {}
|
||||
}
|
||||
},
|
||||
"favoritesFilterAllLabel": "All",
|
||||
"favoriteGroupsChipLabel": "{groupName} · {count}",
|
||||
"@favoriteGroupsChipLabel": {
|
||||
"placeholders": {
|
||||
"groupName": {},
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"favoriteGroupsManage": "Manage lists",
|
||||
"customStationsAddCta": "Add custom station",
|
||||
"alarmPostponedCurrentExecution": "Alarm postponed for this occurrence.",
|
||||
"searchScreenTitle": "Search signal",
|
||||
"searchScreenSubtitle": "Find stations by name, country, or language with fast filters and high contrast.",
|
||||
@@ -298,6 +336,11 @@
|
||||
}
|
||||
},
|
||||
"qualityHd": "HD quality",
|
||||
"yourStationsTitle": "Your stations",
|
||||
"seeAllAction": "See all",
|
||||
"openFullPlayerTooltip": "Open full player",
|
||||
"nothingPlayingTitle": "Nothing playing yet",
|
||||
"nothingPlayingSubtitle": "Pick a station from Your stations or search to start.",
|
||||
"nearYou": "Near you",
|
||||
"nearYouInCountry": "Near you · {country}",
|
||||
"@nearYouInCountry": {
|
||||
|
||||
@@ -55,6 +55,9 @@
|
||||
"settingsSubtitle": "Control fino de sonido, copias de seguridad y emisoras personalizadas.",
|
||||
"settingsGroupAudioTitle": "AUDIO",
|
||||
"settingsGroupStationsTitle": "EMISORAS",
|
||||
"settingsGroupRecordingsTitle": "GRABACIONES Y MÚSICA",
|
||||
"settingsGroupApplicationTitle": "APLICACIÓN",
|
||||
"infoSectionTitle": "Información",
|
||||
"languageSectionTitle": "Idioma",
|
||||
"languageSectionDescription": "Elegí cómo se muestra el idioma de la app.",
|
||||
"languageSystemDefault": "Sistema",
|
||||
@@ -206,6 +209,29 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"recordingsLibraryTitle": "Mis grabaciones",
|
||||
"recordingsLibraryStorageCaption": "{usedMb} MB de {totalMb} MB usados",
|
||||
"@recordingsLibraryStorageCaption": {
|
||||
"placeholders": {
|
||||
"usedMb": {
|
||||
"type": "int"
|
||||
},
|
||||
"totalMb": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"recordingsLibraryEmptyTitle": "Todavía no hay grabaciones",
|
||||
"recordingsLibraryEmptySubtitle": "Las grabaciones que guardes van a aparecer acá.",
|
||||
"recordingActionRename": "Renombrar",
|
||||
"recordingActionShare": "Compartir",
|
||||
"recordingActionDelete": "Eliminar",
|
||||
"recordingRenameDialogTitle": "Renombrar grabación",
|
||||
"recordingRenameLabel": "Nombre",
|
||||
"recordingRenameEmptyError": "Ingresá un nombre",
|
||||
"recordingDeleteConfirmTitle": "¿Eliminar grabación?",
|
||||
"recordingDeleteConfirmMessage": "Esta acción no se puede deshacer.",
|
||||
"recordingsLibrarySettingsTooltip": "Ajustes de grabación",
|
||||
"stationOrderTitle": "Orden de emisoras",
|
||||
"stationOrderByName": "Por nombre",
|
||||
"stationOrderByQuality": "Por calidad",
|
||||
@@ -256,6 +282,18 @@
|
||||
"stationName": {}
|
||||
}
|
||||
},
|
||||
"favoritesFilterAllLabel": "Todas",
|
||||
"favoriteGroupsChipLabel": "{groupName} · {count}",
|
||||
"@favoriteGroupsChipLabel": {
|
||||
"placeholders": {
|
||||
"groupName": {},
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"favoriteGroupsManage": "Gestionar listas",
|
||||
"customStationsAddCta": "Añadir emisora personalizada",
|
||||
"alarmPostponedCurrentExecution": "Alarma pospuesta para esta ejecución.",
|
||||
"searchScreenTitle": "Buscar señal",
|
||||
"searchScreenSubtitle": "Encontrá radios por nombre, país o idioma con filtros rápidos y alto contraste.",
|
||||
@@ -298,6 +336,11 @@
|
||||
}
|
||||
},
|
||||
"qualityHd": "Calidad HD",
|
||||
"yourStationsTitle": "Tus emisoras",
|
||||
"seeAllAction": "Ver todas",
|
||||
"openFullPlayerTooltip": "Abrir reproductor completo",
|
||||
"nothingPlayingTitle": "Todavía no estás escuchando nada",
|
||||
"nothingPlayingSubtitle": "Elegí una emisora de Tus emisoras o buscá una para empezar.",
|
||||
"nearYou": "Cerca de vos",
|
||||
"nearYouInCountry": "Cerca de vos · {country}",
|
||||
"@nearYouInCountry": {
|
||||
|
||||
@@ -286,6 +286,24 @@ abstract class AppLocalizations {
|
||||
/// **'EMISORAS'**
|
||||
String get settingsGroupStationsTitle;
|
||||
|
||||
/// No description provided for @settingsGroupRecordingsTitle.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'GRABACIONES Y MÚSICA'**
|
||||
String get settingsGroupRecordingsTitle;
|
||||
|
||||
/// No description provided for @settingsGroupApplicationTitle.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'APLICACIÓN'**
|
||||
String get settingsGroupApplicationTitle;
|
||||
|
||||
/// No description provided for @infoSectionTitle.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Información'**
|
||||
String get infoSectionTitle;
|
||||
|
||||
/// No description provided for @languageSectionTitle.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
@@ -778,6 +796,84 @@ abstract class AppLocalizations {
|
||||
/// **'Límite de grabación actualizado a {size} MB'**
|
||||
String recordingsMaxSizeSaved(int size);
|
||||
|
||||
/// No description provided for @recordingsLibraryTitle.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Mis grabaciones'**
|
||||
String get recordingsLibraryTitle;
|
||||
|
||||
/// No description provided for @recordingsLibraryStorageCaption.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'{usedMb} MB de {totalMb} MB usados'**
|
||||
String recordingsLibraryStorageCaption(int usedMb, int totalMb);
|
||||
|
||||
/// No description provided for @recordingsLibraryEmptyTitle.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Todavía no hay grabaciones'**
|
||||
String get recordingsLibraryEmptyTitle;
|
||||
|
||||
/// No description provided for @recordingsLibraryEmptySubtitle.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Las grabaciones que guardes van a aparecer acá.'**
|
||||
String get recordingsLibraryEmptySubtitle;
|
||||
|
||||
/// No description provided for @recordingActionRename.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Renombrar'**
|
||||
String get recordingActionRename;
|
||||
|
||||
/// No description provided for @recordingActionShare.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Compartir'**
|
||||
String get recordingActionShare;
|
||||
|
||||
/// No description provided for @recordingActionDelete.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Eliminar'**
|
||||
String get recordingActionDelete;
|
||||
|
||||
/// No description provided for @recordingRenameDialogTitle.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Renombrar grabación'**
|
||||
String get recordingRenameDialogTitle;
|
||||
|
||||
/// No description provided for @recordingRenameLabel.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Nombre'**
|
||||
String get recordingRenameLabel;
|
||||
|
||||
/// No description provided for @recordingRenameEmptyError.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Ingresá un nombre'**
|
||||
String get recordingRenameEmptyError;
|
||||
|
||||
/// No description provided for @recordingDeleteConfirmTitle.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'¿Eliminar grabación?'**
|
||||
String get recordingDeleteConfirmTitle;
|
||||
|
||||
/// No description provided for @recordingDeleteConfirmMessage.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Esta acción no se puede deshacer.'**
|
||||
String get recordingDeleteConfirmMessage;
|
||||
|
||||
/// No description provided for @recordingsLibrarySettingsTooltip.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Ajustes de grabación'**
|
||||
String get recordingsLibrarySettingsTooltip;
|
||||
|
||||
/// No description provided for @stationOrderTitle.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
@@ -940,6 +1036,30 @@ abstract class AppLocalizations {
|
||||
/// **'{stationName} eliminada de favoritos'**
|
||||
String favoritesRemovedMessage(Object stationName);
|
||||
|
||||
/// No description provided for @favoritesFilterAllLabel.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Todas'**
|
||||
String get favoritesFilterAllLabel;
|
||||
|
||||
/// No description provided for @favoriteGroupsChipLabel.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'{groupName} · {count}'**
|
||||
String favoriteGroupsChipLabel(Object groupName, int count);
|
||||
|
||||
/// No description provided for @favoriteGroupsManage.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Gestionar listas'**
|
||||
String get favoriteGroupsManage;
|
||||
|
||||
/// No description provided for @customStationsAddCta.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Añadir emisora personalizada'**
|
||||
String get customStationsAddCta;
|
||||
|
||||
/// No description provided for @alarmPostponedCurrentExecution.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
@@ -1150,6 +1270,36 @@ abstract class AppLocalizations {
|
||||
/// **'Calidad HD'**
|
||||
String get qualityHd;
|
||||
|
||||
/// No description provided for @yourStationsTitle.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Tus emisoras'**
|
||||
String get yourStationsTitle;
|
||||
|
||||
/// No description provided for @seeAllAction.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Ver todas'**
|
||||
String get seeAllAction;
|
||||
|
||||
/// No description provided for @openFullPlayerTooltip.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Abrir reproductor completo'**
|
||||
String get openFullPlayerTooltip;
|
||||
|
||||
/// No description provided for @nothingPlayingTitle.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Todavía no estás escuchando nada'**
|
||||
String get nothingPlayingTitle;
|
||||
|
||||
/// No description provided for @nothingPlayingSubtitle.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Elegí una emisora de Tus emisoras o buscá una para empezar.'**
|
||||
String get nothingPlayingSubtitle;
|
||||
|
||||
/// No description provided for @nearYou.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
|
||||
@@ -105,6 +105,15 @@ class AppLocalizationsAr extends AppLocalizations {
|
||||
@override
|
||||
String get settingsGroupStationsTitle => 'EMISORAS';
|
||||
|
||||
@override
|
||||
String get settingsGroupRecordingsTitle => 'GRABACIONES Y MÚSICA';
|
||||
|
||||
@override
|
||||
String get settingsGroupApplicationTitle => 'APLICACIÓN';
|
||||
|
||||
@override
|
||||
String get infoSectionTitle => 'Información';
|
||||
|
||||
@override
|
||||
String get languageSectionTitle => 'اللغة';
|
||||
|
||||
@@ -386,6 +395,49 @@ class AppLocalizationsAr extends AppLocalizations {
|
||||
return 'تم تحديث حد التسجيل إلى $size ميغابايت';
|
||||
}
|
||||
|
||||
@override
|
||||
String get recordingsLibraryTitle => 'Mis grabaciones';
|
||||
|
||||
@override
|
||||
String recordingsLibraryStorageCaption(int usedMb, int totalMb) {
|
||||
return '$usedMb MB de $totalMb MB usados';
|
||||
}
|
||||
|
||||
@override
|
||||
String get recordingsLibraryEmptyTitle => 'Todavía no hay grabaciones';
|
||||
|
||||
@override
|
||||
String get recordingsLibraryEmptySubtitle =>
|
||||
'Las grabaciones que guardes van a aparecer acá.';
|
||||
|
||||
@override
|
||||
String get recordingActionRename => 'Renombrar';
|
||||
|
||||
@override
|
||||
String get recordingActionShare => 'Compartir';
|
||||
|
||||
@override
|
||||
String get recordingActionDelete => 'Eliminar';
|
||||
|
||||
@override
|
||||
String get recordingRenameDialogTitle => 'Renombrar grabación';
|
||||
|
||||
@override
|
||||
String get recordingRenameLabel => 'Nombre';
|
||||
|
||||
@override
|
||||
String get recordingRenameEmptyError => 'Ingresá un nombre';
|
||||
|
||||
@override
|
||||
String get recordingDeleteConfirmTitle => '¿Eliminar grabación?';
|
||||
|
||||
@override
|
||||
String get recordingDeleteConfirmMessage =>
|
||||
'Esta acción no se puede deshacer.';
|
||||
|
||||
@override
|
||||
String get recordingsLibrarySettingsTooltip => 'Ajustes de grabación';
|
||||
|
||||
@override
|
||||
String get stationOrderTitle => 'ترتيب المحطات';
|
||||
|
||||
@@ -481,6 +533,20 @@ class AppLocalizationsAr extends AppLocalizations {
|
||||
return 'تمت إزالة $stationName من المفضلة';
|
||||
}
|
||||
|
||||
@override
|
||||
String get favoritesFilterAllLabel => 'Todas';
|
||||
|
||||
@override
|
||||
String favoriteGroupsChipLabel(Object groupName, int count) {
|
||||
return '$groupName · $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String get favoriteGroupsManage => 'Gestionar listas';
|
||||
|
||||
@override
|
||||
String get customStationsAddCta => 'Añadir emisora personalizada';
|
||||
|
||||
@override
|
||||
String get alarmPostponedCurrentExecution => 'تم تأجيل المنبه لهذا التشغيل.';
|
||||
|
||||
@@ -592,6 +658,22 @@ class AppLocalizationsAr extends AppLocalizations {
|
||||
@override
|
||||
String get qualityHd => 'جودة HD';
|
||||
|
||||
@override
|
||||
String get yourStationsTitle => 'Tus emisoras';
|
||||
|
||||
@override
|
||||
String get seeAllAction => 'Ver todas';
|
||||
|
||||
@override
|
||||
String get openFullPlayerTooltip => 'Abrir reproductor completo';
|
||||
|
||||
@override
|
||||
String get nothingPlayingTitle => 'Todavía no estás escuchando nada';
|
||||
|
||||
@override
|
||||
String get nothingPlayingSubtitle =>
|
||||
'Elegí una emisora de Tus emisoras o buscá una para empezar.';
|
||||
|
||||
@override
|
||||
String get nearYou => 'بالقرب منك';
|
||||
|
||||
|
||||
@@ -106,6 +106,15 @@ class AppLocalizationsBn extends AppLocalizations {
|
||||
@override
|
||||
String get settingsGroupStationsTitle => 'EMISORAS';
|
||||
|
||||
@override
|
||||
String get settingsGroupRecordingsTitle => 'GRABACIONES Y MÚSICA';
|
||||
|
||||
@override
|
||||
String get settingsGroupApplicationTitle => 'APLICACIÓN';
|
||||
|
||||
@override
|
||||
String get infoSectionTitle => 'Información';
|
||||
|
||||
@override
|
||||
String get languageSectionTitle => 'ভাষা';
|
||||
|
||||
@@ -389,6 +398,49 @@ class AppLocalizationsBn extends AppLocalizations {
|
||||
return 'রেকর্ডিং সীমা $size MB-এ আপডেট হয়েছে';
|
||||
}
|
||||
|
||||
@override
|
||||
String get recordingsLibraryTitle => 'Mis grabaciones';
|
||||
|
||||
@override
|
||||
String recordingsLibraryStorageCaption(int usedMb, int totalMb) {
|
||||
return '$usedMb MB de $totalMb MB usados';
|
||||
}
|
||||
|
||||
@override
|
||||
String get recordingsLibraryEmptyTitle => 'Todavía no hay grabaciones';
|
||||
|
||||
@override
|
||||
String get recordingsLibraryEmptySubtitle =>
|
||||
'Las grabaciones que guardes van a aparecer acá.';
|
||||
|
||||
@override
|
||||
String get recordingActionRename => 'Renombrar';
|
||||
|
||||
@override
|
||||
String get recordingActionShare => 'Compartir';
|
||||
|
||||
@override
|
||||
String get recordingActionDelete => 'Eliminar';
|
||||
|
||||
@override
|
||||
String get recordingRenameDialogTitle => 'Renombrar grabación';
|
||||
|
||||
@override
|
||||
String get recordingRenameLabel => 'Nombre';
|
||||
|
||||
@override
|
||||
String get recordingRenameEmptyError => 'Ingresá un nombre';
|
||||
|
||||
@override
|
||||
String get recordingDeleteConfirmTitle => '¿Eliminar grabación?';
|
||||
|
||||
@override
|
||||
String get recordingDeleteConfirmMessage =>
|
||||
'Esta acción no se puede deshacer.';
|
||||
|
||||
@override
|
||||
String get recordingsLibrarySettingsTooltip => 'Ajustes de grabación';
|
||||
|
||||
@override
|
||||
String get stationOrderTitle => 'স্টেশনের ক্রম';
|
||||
|
||||
@@ -484,6 +536,20 @@ class AppLocalizationsBn extends AppLocalizations {
|
||||
return '$stationName প্রিয় থেকে সরানো হয়েছে';
|
||||
}
|
||||
|
||||
@override
|
||||
String get favoritesFilterAllLabel => 'Todas';
|
||||
|
||||
@override
|
||||
String favoriteGroupsChipLabel(Object groupName, int count) {
|
||||
return '$groupName · $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String get favoriteGroupsManage => 'Gestionar listas';
|
||||
|
||||
@override
|
||||
String get customStationsAddCta => 'Añadir emisora personalizada';
|
||||
|
||||
@override
|
||||
String get alarmPostponedCurrentExecution =>
|
||||
'এই চালনার জন্য অ্যালার্ম পিছিয়ে দেওয়া হয়েছে।';
|
||||
@@ -596,6 +662,22 @@ class AppLocalizationsBn extends AppLocalizations {
|
||||
@override
|
||||
String get qualityHd => 'HD গুণমান';
|
||||
|
||||
@override
|
||||
String get yourStationsTitle => 'Tus emisoras';
|
||||
|
||||
@override
|
||||
String get seeAllAction => 'Ver todas';
|
||||
|
||||
@override
|
||||
String get openFullPlayerTooltip => 'Abrir reproductor completo';
|
||||
|
||||
@override
|
||||
String get nothingPlayingTitle => 'Todavía no estás escuchando nada';
|
||||
|
||||
@override
|
||||
String get nothingPlayingSubtitle =>
|
||||
'Elegí una emisora de Tus emisoras o buscá una para empezar.';
|
||||
|
||||
@override
|
||||
String get nearYou => 'আপনার কাছাকাছি';
|
||||
|
||||
|
||||
@@ -105,6 +105,15 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get settingsGroupStationsTitle => 'EMISORAS';
|
||||
|
||||
@override
|
||||
String get settingsGroupRecordingsTitle => 'GRABACIONES Y MÚSICA';
|
||||
|
||||
@override
|
||||
String get settingsGroupApplicationTitle => 'APLICACIÓN';
|
||||
|
||||
@override
|
||||
String get infoSectionTitle => 'Información';
|
||||
|
||||
@override
|
||||
String get languageSectionTitle => 'Sprache';
|
||||
|
||||
@@ -392,6 +401,49 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
return 'Aufnahmelimit auf $size MB aktualisiert';
|
||||
}
|
||||
|
||||
@override
|
||||
String get recordingsLibraryTitle => 'Mis grabaciones';
|
||||
|
||||
@override
|
||||
String recordingsLibraryStorageCaption(int usedMb, int totalMb) {
|
||||
return '$usedMb MB de $totalMb MB usados';
|
||||
}
|
||||
|
||||
@override
|
||||
String get recordingsLibraryEmptyTitle => 'Todavía no hay grabaciones';
|
||||
|
||||
@override
|
||||
String get recordingsLibraryEmptySubtitle =>
|
||||
'Las grabaciones que guardes van a aparecer acá.';
|
||||
|
||||
@override
|
||||
String get recordingActionRename => 'Renombrar';
|
||||
|
||||
@override
|
||||
String get recordingActionShare => 'Compartir';
|
||||
|
||||
@override
|
||||
String get recordingActionDelete => 'Eliminar';
|
||||
|
||||
@override
|
||||
String get recordingRenameDialogTitle => 'Renombrar grabación';
|
||||
|
||||
@override
|
||||
String get recordingRenameLabel => 'Nombre';
|
||||
|
||||
@override
|
||||
String get recordingRenameEmptyError => 'Ingresá un nombre';
|
||||
|
||||
@override
|
||||
String get recordingDeleteConfirmTitle => '¿Eliminar grabación?';
|
||||
|
||||
@override
|
||||
String get recordingDeleteConfirmMessage =>
|
||||
'Esta acción no se puede deshacer.';
|
||||
|
||||
@override
|
||||
String get recordingsLibrarySettingsTooltip => 'Ajustes de grabación';
|
||||
|
||||
@override
|
||||
String get stationOrderTitle => 'Senderreihenfolge';
|
||||
|
||||
@@ -487,6 +539,20 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
return '$stationName aus Favoriten entfernt';
|
||||
}
|
||||
|
||||
@override
|
||||
String get favoritesFilterAllLabel => 'Todas';
|
||||
|
||||
@override
|
||||
String favoriteGroupsChipLabel(Object groupName, int count) {
|
||||
return '$groupName · $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String get favoriteGroupsManage => 'Gestionar listas';
|
||||
|
||||
@override
|
||||
String get customStationsAddCta => 'Añadir emisora personalizada';
|
||||
|
||||
@override
|
||||
String get alarmPostponedCurrentExecution =>
|
||||
'Alarm für diese Ausführung verschoben.';
|
||||
@@ -599,6 +665,22 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get qualityHd => 'HD-Qualität';
|
||||
|
||||
@override
|
||||
String get yourStationsTitle => 'Tus emisoras';
|
||||
|
||||
@override
|
||||
String get seeAllAction => 'Ver todas';
|
||||
|
||||
@override
|
||||
String get openFullPlayerTooltip => 'Abrir reproductor completo';
|
||||
|
||||
@override
|
||||
String get nothingPlayingTitle => 'Todavía no estás escuchando nada';
|
||||
|
||||
@override
|
||||
String get nothingPlayingSubtitle =>
|
||||
'Elegí una emisora de Tus emisoras o buscá una para empezar.';
|
||||
|
||||
@override
|
||||
String get nearYou => 'In deiner Nähe';
|
||||
|
||||
|
||||
@@ -105,6 +105,15 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get settingsGroupStationsTitle => 'STATIONS';
|
||||
|
||||
@override
|
||||
String get settingsGroupRecordingsTitle => 'RECORDINGS & MUSIC';
|
||||
|
||||
@override
|
||||
String get settingsGroupApplicationTitle => 'APPLICATION';
|
||||
|
||||
@override
|
||||
String get infoSectionTitle => 'Info';
|
||||
|
||||
@override
|
||||
String get languageSectionTitle => 'Language';
|
||||
|
||||
@@ -387,6 +396,48 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
return 'Recording limit updated to $size MB';
|
||||
}
|
||||
|
||||
@override
|
||||
String get recordingsLibraryTitle => 'My recordings';
|
||||
|
||||
@override
|
||||
String recordingsLibraryStorageCaption(int usedMb, int totalMb) {
|
||||
return '$usedMb MB of $totalMb MB used';
|
||||
}
|
||||
|
||||
@override
|
||||
String get recordingsLibraryEmptyTitle => 'No recordings yet';
|
||||
|
||||
@override
|
||||
String get recordingsLibraryEmptySubtitle =>
|
||||
'Recordings you save will appear here.';
|
||||
|
||||
@override
|
||||
String get recordingActionRename => 'Rename';
|
||||
|
||||
@override
|
||||
String get recordingActionShare => 'Share';
|
||||
|
||||
@override
|
||||
String get recordingActionDelete => 'Delete';
|
||||
|
||||
@override
|
||||
String get recordingRenameDialogTitle => 'Rename recording';
|
||||
|
||||
@override
|
||||
String get recordingRenameLabel => 'Name';
|
||||
|
||||
@override
|
||||
String get recordingRenameEmptyError => 'Enter a name';
|
||||
|
||||
@override
|
||||
String get recordingDeleteConfirmTitle => 'Delete recording?';
|
||||
|
||||
@override
|
||||
String get recordingDeleteConfirmMessage => 'This can\'t be undone.';
|
||||
|
||||
@override
|
||||
String get recordingsLibrarySettingsTooltip => 'Recording settings';
|
||||
|
||||
@override
|
||||
String get stationOrderTitle => 'Station order';
|
||||
|
||||
@@ -482,6 +533,20 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
return '$stationName removed from favorites';
|
||||
}
|
||||
|
||||
@override
|
||||
String get favoritesFilterAllLabel => 'All';
|
||||
|
||||
@override
|
||||
String favoriteGroupsChipLabel(Object groupName, int count) {
|
||||
return '$groupName · $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String get favoriteGroupsManage => 'Manage lists';
|
||||
|
||||
@override
|
||||
String get customStationsAddCta => 'Add custom station';
|
||||
|
||||
@override
|
||||
String get alarmPostponedCurrentExecution =>
|
||||
'Alarm postponed for this occurrence.';
|
||||
@@ -594,6 +659,22 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get qualityHd => 'HD quality';
|
||||
|
||||
@override
|
||||
String get yourStationsTitle => 'Your stations';
|
||||
|
||||
@override
|
||||
String get seeAllAction => 'See all';
|
||||
|
||||
@override
|
||||
String get openFullPlayerTooltip => 'Open full player';
|
||||
|
||||
@override
|
||||
String get nothingPlayingTitle => 'Nothing playing yet';
|
||||
|
||||
@override
|
||||
String get nothingPlayingSubtitle =>
|
||||
'Pick a station from Your stations or search to start.';
|
||||
|
||||
@override
|
||||
String get nearYou => 'Near you';
|
||||
|
||||
|
||||
@@ -105,6 +105,15 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get settingsGroupStationsTitle => 'EMISORAS';
|
||||
|
||||
@override
|
||||
String get settingsGroupRecordingsTitle => 'GRABACIONES Y MÚSICA';
|
||||
|
||||
@override
|
||||
String get settingsGroupApplicationTitle => 'APLICACIÓN';
|
||||
|
||||
@override
|
||||
String get infoSectionTitle => 'Información';
|
||||
|
||||
@override
|
||||
String get languageSectionTitle => 'Idioma';
|
||||
|
||||
@@ -390,6 +399,49 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
return 'Límite de grabación actualizado a $size MB';
|
||||
}
|
||||
|
||||
@override
|
||||
String get recordingsLibraryTitle => 'Mis grabaciones';
|
||||
|
||||
@override
|
||||
String recordingsLibraryStorageCaption(int usedMb, int totalMb) {
|
||||
return '$usedMb MB de $totalMb MB usados';
|
||||
}
|
||||
|
||||
@override
|
||||
String get recordingsLibraryEmptyTitle => 'Todavía no hay grabaciones';
|
||||
|
||||
@override
|
||||
String get recordingsLibraryEmptySubtitle =>
|
||||
'Las grabaciones que guardes van a aparecer acá.';
|
||||
|
||||
@override
|
||||
String get recordingActionRename => 'Renombrar';
|
||||
|
||||
@override
|
||||
String get recordingActionShare => 'Compartir';
|
||||
|
||||
@override
|
||||
String get recordingActionDelete => 'Eliminar';
|
||||
|
||||
@override
|
||||
String get recordingRenameDialogTitle => 'Renombrar grabación';
|
||||
|
||||
@override
|
||||
String get recordingRenameLabel => 'Nombre';
|
||||
|
||||
@override
|
||||
String get recordingRenameEmptyError => 'Ingresá un nombre';
|
||||
|
||||
@override
|
||||
String get recordingDeleteConfirmTitle => '¿Eliminar grabación?';
|
||||
|
||||
@override
|
||||
String get recordingDeleteConfirmMessage =>
|
||||
'Esta acción no se puede deshacer.';
|
||||
|
||||
@override
|
||||
String get recordingsLibrarySettingsTooltip => 'Ajustes de grabación';
|
||||
|
||||
@override
|
||||
String get stationOrderTitle => 'Orden de emisoras';
|
||||
|
||||
@@ -485,6 +537,20 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
return '$stationName eliminada de favoritos';
|
||||
}
|
||||
|
||||
@override
|
||||
String get favoritesFilterAllLabel => 'Todas';
|
||||
|
||||
@override
|
||||
String favoriteGroupsChipLabel(Object groupName, int count) {
|
||||
return '$groupName · $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String get favoriteGroupsManage => 'Gestionar listas';
|
||||
|
||||
@override
|
||||
String get customStationsAddCta => 'Añadir emisora personalizada';
|
||||
|
||||
@override
|
||||
String get alarmPostponedCurrentExecution =>
|
||||
'Alarma pospuesta para esta ejecución.';
|
||||
@@ -597,6 +663,22 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get qualityHd => 'Calidad HD';
|
||||
|
||||
@override
|
||||
String get yourStationsTitle => 'Tus emisoras';
|
||||
|
||||
@override
|
||||
String get seeAllAction => 'Ver todas';
|
||||
|
||||
@override
|
||||
String get openFullPlayerTooltip => 'Abrir reproductor completo';
|
||||
|
||||
@override
|
||||
String get nothingPlayingTitle => 'Todavía no estás escuchando nada';
|
||||
|
||||
@override
|
||||
String get nothingPlayingSubtitle =>
|
||||
'Elegí una emisora de Tus emisoras o buscá una para empezar.';
|
||||
|
||||
@override
|
||||
String get nearYou => 'Cerca de vos';
|
||||
|
||||
|
||||
@@ -106,6 +106,15 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get settingsGroupStationsTitle => 'EMISORAS';
|
||||
|
||||
@override
|
||||
String get settingsGroupRecordingsTitle => 'GRABACIONES Y MÚSICA';
|
||||
|
||||
@override
|
||||
String get settingsGroupApplicationTitle => 'APLICACIÓN';
|
||||
|
||||
@override
|
||||
String get infoSectionTitle => 'Información';
|
||||
|
||||
@override
|
||||
String get languageSectionTitle => 'Langue';
|
||||
|
||||
@@ -394,6 +403,49 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
return 'Limite d’enregistrement mise à jour à $size Mo';
|
||||
}
|
||||
|
||||
@override
|
||||
String get recordingsLibraryTitle => 'Mis grabaciones';
|
||||
|
||||
@override
|
||||
String recordingsLibraryStorageCaption(int usedMb, int totalMb) {
|
||||
return '$usedMb MB de $totalMb MB usados';
|
||||
}
|
||||
|
||||
@override
|
||||
String get recordingsLibraryEmptyTitle => 'Todavía no hay grabaciones';
|
||||
|
||||
@override
|
||||
String get recordingsLibraryEmptySubtitle =>
|
||||
'Las grabaciones que guardes van a aparecer acá.';
|
||||
|
||||
@override
|
||||
String get recordingActionRename => 'Renombrar';
|
||||
|
||||
@override
|
||||
String get recordingActionShare => 'Compartir';
|
||||
|
||||
@override
|
||||
String get recordingActionDelete => 'Eliminar';
|
||||
|
||||
@override
|
||||
String get recordingRenameDialogTitle => 'Renombrar grabación';
|
||||
|
||||
@override
|
||||
String get recordingRenameLabel => 'Nombre';
|
||||
|
||||
@override
|
||||
String get recordingRenameEmptyError => 'Ingresá un nombre';
|
||||
|
||||
@override
|
||||
String get recordingDeleteConfirmTitle => '¿Eliminar grabación?';
|
||||
|
||||
@override
|
||||
String get recordingDeleteConfirmMessage =>
|
||||
'Esta acción no se puede deshacer.';
|
||||
|
||||
@override
|
||||
String get recordingsLibrarySettingsTooltip => 'Ajustes de grabación';
|
||||
|
||||
@override
|
||||
String get stationOrderTitle => 'Ordre des stations';
|
||||
|
||||
@@ -489,6 +541,20 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
return '$stationName retirée des favoris';
|
||||
}
|
||||
|
||||
@override
|
||||
String get favoritesFilterAllLabel => 'Todas';
|
||||
|
||||
@override
|
||||
String favoriteGroupsChipLabel(Object groupName, int count) {
|
||||
return '$groupName · $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String get favoriteGroupsManage => 'Gestionar listas';
|
||||
|
||||
@override
|
||||
String get customStationsAddCta => 'Añadir emisora personalizada';
|
||||
|
||||
@override
|
||||
String get alarmPostponedCurrentExecution =>
|
||||
'Alarme reportée pour cette exécution.';
|
||||
@@ -601,6 +667,22 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get qualityHd => 'Qualité HD';
|
||||
|
||||
@override
|
||||
String get yourStationsTitle => 'Tus emisoras';
|
||||
|
||||
@override
|
||||
String get seeAllAction => 'Ver todas';
|
||||
|
||||
@override
|
||||
String get openFullPlayerTooltip => 'Abrir reproductor completo';
|
||||
|
||||
@override
|
||||
String get nothingPlayingTitle => 'Todavía no estás escuchando nada';
|
||||
|
||||
@override
|
||||
String get nothingPlayingSubtitle =>
|
||||
'Elegí una emisora de Tus emisoras o buscá una para empezar.';
|
||||
|
||||
@override
|
||||
String get nearYou => 'Près de vous';
|
||||
|
||||
|
||||
@@ -105,6 +105,15 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get settingsGroupStationsTitle => 'EMISORAS';
|
||||
|
||||
@override
|
||||
String get settingsGroupRecordingsTitle => 'GRABACIONES Y MÚSICA';
|
||||
|
||||
@override
|
||||
String get settingsGroupApplicationTitle => 'APLICACIÓN';
|
||||
|
||||
@override
|
||||
String get infoSectionTitle => 'Información';
|
||||
|
||||
@override
|
||||
String get languageSectionTitle => 'भाषा';
|
||||
|
||||
@@ -387,6 +396,49 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
return 'रिकॉर्डिंग सीमा $size MB पर अपडेट हुई';
|
||||
}
|
||||
|
||||
@override
|
||||
String get recordingsLibraryTitle => 'Mis grabaciones';
|
||||
|
||||
@override
|
||||
String recordingsLibraryStorageCaption(int usedMb, int totalMb) {
|
||||
return '$usedMb MB de $totalMb MB usados';
|
||||
}
|
||||
|
||||
@override
|
||||
String get recordingsLibraryEmptyTitle => 'Todavía no hay grabaciones';
|
||||
|
||||
@override
|
||||
String get recordingsLibraryEmptySubtitle =>
|
||||
'Las grabaciones que guardes van a aparecer acá.';
|
||||
|
||||
@override
|
||||
String get recordingActionRename => 'Renombrar';
|
||||
|
||||
@override
|
||||
String get recordingActionShare => 'Compartir';
|
||||
|
||||
@override
|
||||
String get recordingActionDelete => 'Eliminar';
|
||||
|
||||
@override
|
||||
String get recordingRenameDialogTitle => 'Renombrar grabación';
|
||||
|
||||
@override
|
||||
String get recordingRenameLabel => 'Nombre';
|
||||
|
||||
@override
|
||||
String get recordingRenameEmptyError => 'Ingresá un nombre';
|
||||
|
||||
@override
|
||||
String get recordingDeleteConfirmTitle => '¿Eliminar grabación?';
|
||||
|
||||
@override
|
||||
String get recordingDeleteConfirmMessage =>
|
||||
'Esta acción no se puede deshacer.';
|
||||
|
||||
@override
|
||||
String get recordingsLibrarySettingsTooltip => 'Ajustes de grabación';
|
||||
|
||||
@override
|
||||
String get stationOrderTitle => 'स्टेशन क्रम';
|
||||
|
||||
@@ -482,6 +534,20 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
return '$stationName पसंदीदा से हटाया गया';
|
||||
}
|
||||
|
||||
@override
|
||||
String get favoritesFilterAllLabel => 'Todas';
|
||||
|
||||
@override
|
||||
String favoriteGroupsChipLabel(Object groupName, int count) {
|
||||
return '$groupName · $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String get favoriteGroupsManage => 'Gestionar listas';
|
||||
|
||||
@override
|
||||
String get customStationsAddCta => 'Añadir emisora personalizada';
|
||||
|
||||
@override
|
||||
String get alarmPostponedCurrentExecution =>
|
||||
'इस चाल के लिए अलार्म स्थगित किया गया।';
|
||||
@@ -594,6 +660,22 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get qualityHd => 'HD गुणवत्ता';
|
||||
|
||||
@override
|
||||
String get yourStationsTitle => 'Tus emisoras';
|
||||
|
||||
@override
|
||||
String get seeAllAction => 'Ver todas';
|
||||
|
||||
@override
|
||||
String get openFullPlayerTooltip => 'Abrir reproductor completo';
|
||||
|
||||
@override
|
||||
String get nothingPlayingTitle => 'Todavía no estás escuchando nada';
|
||||
|
||||
@override
|
||||
String get nothingPlayingSubtitle =>
|
||||
'Elegí una emisora de Tus emisoras o buscá una para empezar.';
|
||||
|
||||
@override
|
||||
String get nearYou => 'आपके पास';
|
||||
|
||||
|
||||
@@ -106,6 +106,15 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get settingsGroupStationsTitle => 'EMISORAS';
|
||||
|
||||
@override
|
||||
String get settingsGroupRecordingsTitle => 'GRABACIONES Y MÚSICA';
|
||||
|
||||
@override
|
||||
String get settingsGroupApplicationTitle => 'APLICACIÓN';
|
||||
|
||||
@override
|
||||
String get infoSectionTitle => 'Información';
|
||||
|
||||
@override
|
||||
String get languageSectionTitle => 'Bahasa';
|
||||
|
||||
@@ -388,6 +397,49 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
return 'Batas rekaman diperbarui menjadi $size MB';
|
||||
}
|
||||
|
||||
@override
|
||||
String get recordingsLibraryTitle => 'Mis grabaciones';
|
||||
|
||||
@override
|
||||
String recordingsLibraryStorageCaption(int usedMb, int totalMb) {
|
||||
return '$usedMb MB de $totalMb MB usados';
|
||||
}
|
||||
|
||||
@override
|
||||
String get recordingsLibraryEmptyTitle => 'Todavía no hay grabaciones';
|
||||
|
||||
@override
|
||||
String get recordingsLibraryEmptySubtitle =>
|
||||
'Las grabaciones que guardes van a aparecer acá.';
|
||||
|
||||
@override
|
||||
String get recordingActionRename => 'Renombrar';
|
||||
|
||||
@override
|
||||
String get recordingActionShare => 'Compartir';
|
||||
|
||||
@override
|
||||
String get recordingActionDelete => 'Eliminar';
|
||||
|
||||
@override
|
||||
String get recordingRenameDialogTitle => 'Renombrar grabación';
|
||||
|
||||
@override
|
||||
String get recordingRenameLabel => 'Nombre';
|
||||
|
||||
@override
|
||||
String get recordingRenameEmptyError => 'Ingresá un nombre';
|
||||
|
||||
@override
|
||||
String get recordingDeleteConfirmTitle => '¿Eliminar grabación?';
|
||||
|
||||
@override
|
||||
String get recordingDeleteConfirmMessage =>
|
||||
'Esta acción no se puede deshacer.';
|
||||
|
||||
@override
|
||||
String get recordingsLibrarySettingsTooltip => 'Ajustes de grabación';
|
||||
|
||||
@override
|
||||
String get stationOrderTitle => 'Urutan stasiun';
|
||||
|
||||
@@ -483,6 +535,20 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
return '$stationName dihapus dari favorit';
|
||||
}
|
||||
|
||||
@override
|
||||
String get favoritesFilterAllLabel => 'Todas';
|
||||
|
||||
@override
|
||||
String favoriteGroupsChipLabel(Object groupName, int count) {
|
||||
return '$groupName · $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String get favoriteGroupsManage => 'Gestionar listas';
|
||||
|
||||
@override
|
||||
String get customStationsAddCta => 'Añadir emisora personalizada';
|
||||
|
||||
@override
|
||||
String get alarmPostponedCurrentExecution =>
|
||||
'Alarm ditunda untuk eksekusi ini.';
|
||||
@@ -595,6 +661,22 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get qualityHd => 'Kualitas HD';
|
||||
|
||||
@override
|
||||
String get yourStationsTitle => 'Tus emisoras';
|
||||
|
||||
@override
|
||||
String get seeAllAction => 'Ver todas';
|
||||
|
||||
@override
|
||||
String get openFullPlayerTooltip => 'Abrir reproductor completo';
|
||||
|
||||
@override
|
||||
String get nothingPlayingTitle => 'Todavía no estás escuchando nada';
|
||||
|
||||
@override
|
||||
String get nothingPlayingSubtitle =>
|
||||
'Elegí una emisora de Tus emisoras o buscá una para empezar.';
|
||||
|
||||
@override
|
||||
String get nearYou => 'Di dekat Anda';
|
||||
|
||||
|
||||
@@ -105,6 +105,15 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
@override
|
||||
String get settingsGroupStationsTitle => 'EMISORAS';
|
||||
|
||||
@override
|
||||
String get settingsGroupRecordingsTitle => 'GRABACIONES Y MÚSICA';
|
||||
|
||||
@override
|
||||
String get settingsGroupApplicationTitle => 'APLICACIÓN';
|
||||
|
||||
@override
|
||||
String get infoSectionTitle => 'Información';
|
||||
|
||||
@override
|
||||
String get languageSectionTitle => 'Lingua';
|
||||
|
||||
@@ -390,6 +399,49 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
return 'Limite di registrazione aggiornato a $size MB';
|
||||
}
|
||||
|
||||
@override
|
||||
String get recordingsLibraryTitle => 'Mis grabaciones';
|
||||
|
||||
@override
|
||||
String recordingsLibraryStorageCaption(int usedMb, int totalMb) {
|
||||
return '$usedMb MB de $totalMb MB usados';
|
||||
}
|
||||
|
||||
@override
|
||||
String get recordingsLibraryEmptyTitle => 'Todavía no hay grabaciones';
|
||||
|
||||
@override
|
||||
String get recordingsLibraryEmptySubtitle =>
|
||||
'Las grabaciones que guardes van a aparecer acá.';
|
||||
|
||||
@override
|
||||
String get recordingActionRename => 'Renombrar';
|
||||
|
||||
@override
|
||||
String get recordingActionShare => 'Compartir';
|
||||
|
||||
@override
|
||||
String get recordingActionDelete => 'Eliminar';
|
||||
|
||||
@override
|
||||
String get recordingRenameDialogTitle => 'Renombrar grabación';
|
||||
|
||||
@override
|
||||
String get recordingRenameLabel => 'Nombre';
|
||||
|
||||
@override
|
||||
String get recordingRenameEmptyError => 'Ingresá un nombre';
|
||||
|
||||
@override
|
||||
String get recordingDeleteConfirmTitle => '¿Eliminar grabación?';
|
||||
|
||||
@override
|
||||
String get recordingDeleteConfirmMessage =>
|
||||
'Esta acción no se puede deshacer.';
|
||||
|
||||
@override
|
||||
String get recordingsLibrarySettingsTooltip => 'Ajustes de grabación';
|
||||
|
||||
@override
|
||||
String get stationOrderTitle => 'Ordine emittenti';
|
||||
|
||||
@@ -485,6 +537,20 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
return '$stationName rimossa dai preferiti';
|
||||
}
|
||||
|
||||
@override
|
||||
String get favoritesFilterAllLabel => 'Todas';
|
||||
|
||||
@override
|
||||
String favoriteGroupsChipLabel(Object groupName, int count) {
|
||||
return '$groupName · $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String get favoriteGroupsManage => 'Gestionar listas';
|
||||
|
||||
@override
|
||||
String get customStationsAddCta => 'Añadir emisora personalizada';
|
||||
|
||||
@override
|
||||
String get alarmPostponedCurrentExecution =>
|
||||
'Sveglia posticipata per questa esecuzione.';
|
||||
@@ -597,6 +663,22 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
@override
|
||||
String get qualityHd => 'Qualità HD';
|
||||
|
||||
@override
|
||||
String get yourStationsTitle => 'Tus emisoras';
|
||||
|
||||
@override
|
||||
String get seeAllAction => 'Ver todas';
|
||||
|
||||
@override
|
||||
String get openFullPlayerTooltip => 'Abrir reproductor completo';
|
||||
|
||||
@override
|
||||
String get nothingPlayingTitle => 'Todavía no estás escuchando nada';
|
||||
|
||||
@override
|
||||
String get nothingPlayingSubtitle =>
|
||||
'Elegí una emisora de Tus emisoras o buscá una para empezar.';
|
||||
|
||||
@override
|
||||
String get nearYou => 'Vicino a te';
|
||||
|
||||
|
||||
@@ -103,6 +103,15 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get settingsGroupStationsTitle => 'EMISORAS';
|
||||
|
||||
@override
|
||||
String get settingsGroupRecordingsTitle => 'GRABACIONES Y MÚSICA';
|
||||
|
||||
@override
|
||||
String get settingsGroupApplicationTitle => 'APLICACIÓN';
|
||||
|
||||
@override
|
||||
String get infoSectionTitle => 'Información';
|
||||
|
||||
@override
|
||||
String get languageSectionTitle => '言語';
|
||||
|
||||
@@ -377,6 +386,49 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
return '録音上限を $size MB に更新しました';
|
||||
}
|
||||
|
||||
@override
|
||||
String get recordingsLibraryTitle => 'Mis grabaciones';
|
||||
|
||||
@override
|
||||
String recordingsLibraryStorageCaption(int usedMb, int totalMb) {
|
||||
return '$usedMb MB de $totalMb MB usados';
|
||||
}
|
||||
|
||||
@override
|
||||
String get recordingsLibraryEmptyTitle => 'Todavía no hay grabaciones';
|
||||
|
||||
@override
|
||||
String get recordingsLibraryEmptySubtitle =>
|
||||
'Las grabaciones que guardes van a aparecer acá.';
|
||||
|
||||
@override
|
||||
String get recordingActionRename => 'Renombrar';
|
||||
|
||||
@override
|
||||
String get recordingActionShare => 'Compartir';
|
||||
|
||||
@override
|
||||
String get recordingActionDelete => 'Eliminar';
|
||||
|
||||
@override
|
||||
String get recordingRenameDialogTitle => 'Renombrar grabación';
|
||||
|
||||
@override
|
||||
String get recordingRenameLabel => 'Nombre';
|
||||
|
||||
@override
|
||||
String get recordingRenameEmptyError => 'Ingresá un nombre';
|
||||
|
||||
@override
|
||||
String get recordingDeleteConfirmTitle => '¿Eliminar grabación?';
|
||||
|
||||
@override
|
||||
String get recordingDeleteConfirmMessage =>
|
||||
'Esta acción no se puede deshacer.';
|
||||
|
||||
@override
|
||||
String get recordingsLibrarySettingsTooltip => 'Ajustes de grabación';
|
||||
|
||||
@override
|
||||
String get stationOrderTitle => '局の並び順';
|
||||
|
||||
@@ -466,6 +518,20 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
return '$stationName をお気に入りから削除しました';
|
||||
}
|
||||
|
||||
@override
|
||||
String get favoritesFilterAllLabel => 'Todas';
|
||||
|
||||
@override
|
||||
String favoriteGroupsChipLabel(Object groupName, int count) {
|
||||
return '$groupName · $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String get favoriteGroupsManage => 'Gestionar listas';
|
||||
|
||||
@override
|
||||
String get customStationsAddCta => 'Añadir emisora personalizada';
|
||||
|
||||
@override
|
||||
String get alarmPostponedCurrentExecution => 'この実行のアラームを延期しました。';
|
||||
|
||||
@@ -574,6 +640,22 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get qualityHd => 'HD品質';
|
||||
|
||||
@override
|
||||
String get yourStationsTitle => 'Tus emisoras';
|
||||
|
||||
@override
|
||||
String get seeAllAction => 'Ver todas';
|
||||
|
||||
@override
|
||||
String get openFullPlayerTooltip => 'Abrir reproductor completo';
|
||||
|
||||
@override
|
||||
String get nothingPlayingTitle => 'Todavía no estás escuchando nada';
|
||||
|
||||
@override
|
||||
String get nothingPlayingSubtitle =>
|
||||
'Elegí una emisora de Tus emisoras o buscá una para empezar.';
|
||||
|
||||
@override
|
||||
String get nearYou => '近く';
|
||||
|
||||
|
||||
@@ -105,6 +105,15 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get settingsGroupStationsTitle => 'EMISORAS';
|
||||
|
||||
@override
|
||||
String get settingsGroupRecordingsTitle => 'GRABACIONES Y MÚSICA';
|
||||
|
||||
@override
|
||||
String get settingsGroupApplicationTitle => 'APLICACIÓN';
|
||||
|
||||
@override
|
||||
String get infoSectionTitle => 'Información';
|
||||
|
||||
@override
|
||||
String get languageSectionTitle => 'Idioma';
|
||||
|
||||
@@ -389,6 +398,49 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
return 'Limite de gravação atualizado para $size MB';
|
||||
}
|
||||
|
||||
@override
|
||||
String get recordingsLibraryTitle => 'Mis grabaciones';
|
||||
|
||||
@override
|
||||
String recordingsLibraryStorageCaption(int usedMb, int totalMb) {
|
||||
return '$usedMb MB de $totalMb MB usados';
|
||||
}
|
||||
|
||||
@override
|
||||
String get recordingsLibraryEmptyTitle => 'Todavía no hay grabaciones';
|
||||
|
||||
@override
|
||||
String get recordingsLibraryEmptySubtitle =>
|
||||
'Las grabaciones que guardes van a aparecer acá.';
|
||||
|
||||
@override
|
||||
String get recordingActionRename => 'Renombrar';
|
||||
|
||||
@override
|
||||
String get recordingActionShare => 'Compartir';
|
||||
|
||||
@override
|
||||
String get recordingActionDelete => 'Eliminar';
|
||||
|
||||
@override
|
||||
String get recordingRenameDialogTitle => 'Renombrar grabación';
|
||||
|
||||
@override
|
||||
String get recordingRenameLabel => 'Nombre';
|
||||
|
||||
@override
|
||||
String get recordingRenameEmptyError => 'Ingresá un nombre';
|
||||
|
||||
@override
|
||||
String get recordingDeleteConfirmTitle => '¿Eliminar grabación?';
|
||||
|
||||
@override
|
||||
String get recordingDeleteConfirmMessage =>
|
||||
'Esta acción no se puede deshacer.';
|
||||
|
||||
@override
|
||||
String get recordingsLibrarySettingsTooltip => 'Ajustes de grabación';
|
||||
|
||||
@override
|
||||
String get stationOrderTitle => 'Ordem das estações';
|
||||
|
||||
@@ -484,6 +536,20 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
return '$stationName removida dos favoritos';
|
||||
}
|
||||
|
||||
@override
|
||||
String get favoritesFilterAllLabel => 'Todas';
|
||||
|
||||
@override
|
||||
String favoriteGroupsChipLabel(Object groupName, int count) {
|
||||
return '$groupName · $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String get favoriteGroupsManage => 'Gestionar listas';
|
||||
|
||||
@override
|
||||
String get customStationsAddCta => 'Añadir emisora personalizada';
|
||||
|
||||
@override
|
||||
String get alarmPostponedCurrentExecution =>
|
||||
'Alarme adiado para esta execução.';
|
||||
@@ -596,6 +662,22 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get qualityHd => 'Qualidade HD';
|
||||
|
||||
@override
|
||||
String get yourStationsTitle => 'Tus emisoras';
|
||||
|
||||
@override
|
||||
String get seeAllAction => 'Ver todas';
|
||||
|
||||
@override
|
||||
String get openFullPlayerTooltip => 'Abrir reproductor completo';
|
||||
|
||||
@override
|
||||
String get nothingPlayingTitle => 'Todavía no estás escuchando nada';
|
||||
|
||||
@override
|
||||
String get nothingPlayingSubtitle =>
|
||||
'Elegí una emisora de Tus emisoras o buscá una para empezar.';
|
||||
|
||||
@override
|
||||
String get nearYou => 'Perto de você';
|
||||
|
||||
|
||||
@@ -105,6 +105,15 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get settingsGroupStationsTitle => 'EMISORAS';
|
||||
|
||||
@override
|
||||
String get settingsGroupRecordingsTitle => 'GRABACIONES Y MÚSICA';
|
||||
|
||||
@override
|
||||
String get settingsGroupApplicationTitle => 'APLICACIÓN';
|
||||
|
||||
@override
|
||||
String get infoSectionTitle => 'Información';
|
||||
|
||||
@override
|
||||
String get languageSectionTitle => 'Язык';
|
||||
|
||||
@@ -390,6 +399,49 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
return 'Лимит записи обновлён до $size МБ';
|
||||
}
|
||||
|
||||
@override
|
||||
String get recordingsLibraryTitle => 'Mis grabaciones';
|
||||
|
||||
@override
|
||||
String recordingsLibraryStorageCaption(int usedMb, int totalMb) {
|
||||
return '$usedMb MB de $totalMb MB usados';
|
||||
}
|
||||
|
||||
@override
|
||||
String get recordingsLibraryEmptyTitle => 'Todavía no hay grabaciones';
|
||||
|
||||
@override
|
||||
String get recordingsLibraryEmptySubtitle =>
|
||||
'Las grabaciones que guardes van a aparecer acá.';
|
||||
|
||||
@override
|
||||
String get recordingActionRename => 'Renombrar';
|
||||
|
||||
@override
|
||||
String get recordingActionShare => 'Compartir';
|
||||
|
||||
@override
|
||||
String get recordingActionDelete => 'Eliminar';
|
||||
|
||||
@override
|
||||
String get recordingRenameDialogTitle => 'Renombrar grabación';
|
||||
|
||||
@override
|
||||
String get recordingRenameLabel => 'Nombre';
|
||||
|
||||
@override
|
||||
String get recordingRenameEmptyError => 'Ingresá un nombre';
|
||||
|
||||
@override
|
||||
String get recordingDeleteConfirmTitle => '¿Eliminar grabación?';
|
||||
|
||||
@override
|
||||
String get recordingDeleteConfirmMessage =>
|
||||
'Esta acción no se puede deshacer.';
|
||||
|
||||
@override
|
||||
String get recordingsLibrarySettingsTooltip => 'Ajustes de grabación';
|
||||
|
||||
@override
|
||||
String get stationOrderTitle => 'Порядок станций';
|
||||
|
||||
@@ -485,6 +537,20 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
return '$stationName удалена из избранного';
|
||||
}
|
||||
|
||||
@override
|
||||
String get favoritesFilterAllLabel => 'Todas';
|
||||
|
||||
@override
|
||||
String favoriteGroupsChipLabel(Object groupName, int count) {
|
||||
return '$groupName · $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String get favoriteGroupsManage => 'Gestionar listas';
|
||||
|
||||
@override
|
||||
String get customStationsAddCta => 'Añadir emisora personalizada';
|
||||
|
||||
@override
|
||||
String get alarmPostponedCurrentExecution =>
|
||||
'Будильник отложен для этого запуска.';
|
||||
@@ -597,6 +663,22 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get qualityHd => 'HD-качество';
|
||||
|
||||
@override
|
||||
String get yourStationsTitle => 'Tus emisoras';
|
||||
|
||||
@override
|
||||
String get seeAllAction => 'Ver todas';
|
||||
|
||||
@override
|
||||
String get openFullPlayerTooltip => 'Abrir reproductor completo';
|
||||
|
||||
@override
|
||||
String get nothingPlayingTitle => 'Todavía no estás escuchando nada';
|
||||
|
||||
@override
|
||||
String get nothingPlayingSubtitle =>
|
||||
'Elegí una emisora de Tus emisoras o buscá una para empezar.';
|
||||
|
||||
@override
|
||||
String get nearYou => 'Рядом с вами';
|
||||
|
||||
|
||||
@@ -103,6 +103,15 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get settingsGroupStationsTitle => 'EMISORAS';
|
||||
|
||||
@override
|
||||
String get settingsGroupRecordingsTitle => 'GRABACIONES Y MÚSICA';
|
||||
|
||||
@override
|
||||
String get settingsGroupApplicationTitle => 'APLICACIÓN';
|
||||
|
||||
@override
|
||||
String get infoSectionTitle => 'Información';
|
||||
|
||||
@override
|
||||
String get languageSectionTitle => '语言';
|
||||
|
||||
@@ -375,6 +384,49 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
return '录音限制已更新为 $size MB';
|
||||
}
|
||||
|
||||
@override
|
||||
String get recordingsLibraryTitle => 'Mis grabaciones';
|
||||
|
||||
@override
|
||||
String recordingsLibraryStorageCaption(int usedMb, int totalMb) {
|
||||
return '$usedMb MB de $totalMb MB usados';
|
||||
}
|
||||
|
||||
@override
|
||||
String get recordingsLibraryEmptyTitle => 'Todavía no hay grabaciones';
|
||||
|
||||
@override
|
||||
String get recordingsLibraryEmptySubtitle =>
|
||||
'Las grabaciones que guardes van a aparecer acá.';
|
||||
|
||||
@override
|
||||
String get recordingActionRename => 'Renombrar';
|
||||
|
||||
@override
|
||||
String get recordingActionShare => 'Compartir';
|
||||
|
||||
@override
|
||||
String get recordingActionDelete => 'Eliminar';
|
||||
|
||||
@override
|
||||
String get recordingRenameDialogTitle => 'Renombrar grabación';
|
||||
|
||||
@override
|
||||
String get recordingRenameLabel => 'Nombre';
|
||||
|
||||
@override
|
||||
String get recordingRenameEmptyError => 'Ingresá un nombre';
|
||||
|
||||
@override
|
||||
String get recordingDeleteConfirmTitle => '¿Eliminar grabación?';
|
||||
|
||||
@override
|
||||
String get recordingDeleteConfirmMessage =>
|
||||
'Esta acción no se puede deshacer.';
|
||||
|
||||
@override
|
||||
String get recordingsLibrarySettingsTooltip => 'Ajustes de grabación';
|
||||
|
||||
@override
|
||||
String get stationOrderTitle => '电台排序';
|
||||
|
||||
@@ -464,6 +516,20 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
return '$stationName 已从收藏中移除';
|
||||
}
|
||||
|
||||
@override
|
||||
String get favoritesFilterAllLabel => 'Todas';
|
||||
|
||||
@override
|
||||
String favoriteGroupsChipLabel(Object groupName, int count) {
|
||||
return '$groupName · $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String get favoriteGroupsManage => 'Gestionar listas';
|
||||
|
||||
@override
|
||||
String get customStationsAddCta => 'Añadir emisora personalizada';
|
||||
|
||||
@override
|
||||
String get alarmPostponedCurrentExecution => '本次闹钟已推迟。';
|
||||
|
||||
@@ -571,6 +637,22 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get qualityHd => '高清音质';
|
||||
|
||||
@override
|
||||
String get yourStationsTitle => 'Tus emisoras';
|
||||
|
||||
@override
|
||||
String get seeAllAction => 'Ver todas';
|
||||
|
||||
@override
|
||||
String get openFullPlayerTooltip => 'Abrir reproductor completo';
|
||||
|
||||
@override
|
||||
String get nothingPlayingTitle => 'Todavía no estás escuchando nada';
|
||||
|
||||
@override
|
||||
String get nothingPlayingSubtitle =>
|
||||
'Elegí una emisora de Tus emisoras o buscá una para empezar.';
|
||||
|
||||
@override
|
||||
String get nearYou => '你附近';
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/// A single recorded audio file on disk, as listed by
|
||||
/// `ServicioGrabacionRadio.listarGrabaciones()` (WU15, recordings-library
|
||||
/// spec, "Browsable Recordings List"). Pure filesystem metadata only — no
|
||||
/// embedded-audio decoding here; duration is resolved separately and
|
||||
/// lazily by the screen's own playback abstraction, since decoding audio
|
||||
/// is not something this service conceptually does today.
|
||||
class ArchivoGrabacion {
|
||||
const ArchivoGrabacion({
|
||||
required this.ruta,
|
||||
required this.nombre,
|
||||
required this.fecha,
|
||||
required this.tamanoBytes,
|
||||
});
|
||||
|
||||
/// Full filesystem path — the identity used for playback, rename and
|
||||
/// delete.
|
||||
final String ruta;
|
||||
|
||||
/// Display name: the filename without its extension.
|
||||
final String nombre;
|
||||
|
||||
/// Last-modified timestamp, used as the recording's date.
|
||||
final DateTime fecha;
|
||||
|
||||
final int tamanoBytes;
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:share_plus/share_plus.dart' show Share, XFile;
|
||||
|
||||
import '../../estado/estado_radio.dart';
|
||||
import '../../l10n/gen/app_localizations.dart';
|
||||
import '../../widgets/pluri_glass_surface.dart';
|
||||
import '../../widgets/pluri_layout.dart';
|
||||
import '../../widgets/pluri_push_scaffold.dart';
|
||||
|
||||
/// APLICACIÓN group · "Copia de seguridad" (design ADR-3). Body moved
|
||||
/// verbatim from the former `_SeccionBackup` 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.
|
||||
class PantallaAjustesBackup extends StatelessWidget {
|
||||
const PantallaAjustesBackup({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return PluriPushScaffold(
|
||||
title: l10n.backupSectionTitle,
|
||||
body: ListView(
|
||||
padding: PluriLayout.pageContentPadding,
|
||||
children: const [_CuerpoBackup()],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CuerpoBackup extends StatelessWidget {
|
||||
const _CuerpoBackup();
|
||||
|
||||
Future<void> _exportar(BuildContext context) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
try {
|
||||
final estado = context.read<EstadoRadio>();
|
||||
// JSON serialization is owned by ServicioExportImport (S4-R4).
|
||||
final json = await estado.exportarConfigJson();
|
||||
|
||||
final dir = await getTemporaryDirectory();
|
||||
final file = File('${dir.path}/pluriwave-backup.json');
|
||||
await file.writeAsString(json);
|
||||
|
||||
await Share.shareXFiles(
|
||||
[XFile(file.path)],
|
||||
subject: l10n.backupShareSubject,
|
||||
text: l10n.backupShareText(DateTime.now().toLocal()),
|
||||
);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l10n.backupExportError(e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _importar(BuildContext context) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
try {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['json'],
|
||||
);
|
||||
if (result == null || result.files.single.path == null) return;
|
||||
|
||||
final file = File(result.files.single.path!);
|
||||
final contenido = await file.readAsString();
|
||||
if (!context.mounted) return;
|
||||
// Parsing is owned by ServicioExportImport (S4-R4): null = malformed.
|
||||
final json = context.read<EstadoRadio>().parsearConfigJson(contenido);
|
||||
if (json == null) {
|
||||
throw const FormatException('invalid backup file');
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
final confirmar = await showDialog<bool>(
|
||||
context: context,
|
||||
builder:
|
||||
(ctx) => AlertDialog(
|
||||
title: Text(AppLocalizations.of(ctx).backupImportTitle),
|
||||
content: Text(
|
||||
AppLocalizations.of(ctx).backupImportConfirmMessage,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: Text(AppLocalizations.of(ctx).cancelAction),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: Text(AppLocalizations.of(ctx).backupImportTitle),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmar != true) return;
|
||||
if (context.mounted) {
|
||||
final estado = context.read<EstadoRadio>();
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
await estado.importarConfig(json);
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.backupImportSuccess)),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l10n.backupImportError(e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PluriGlassSurface(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.upload_outlined),
|
||||
title: Text(AppLocalizations.of(context).backupExportTitle),
|
||||
subtitle: Text(AppLocalizations.of(context).backupExportSubtitle),
|
||||
onTap: () => _exportar(context),
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.download_outlined),
|
||||
title: Text(AppLocalizations.of(context).backupImportTitle),
|
||||
subtitle: Text(AppLocalizations.of(context).backupImportSubtitle),
|
||||
onTap: () => _importar(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import '../../widgets/pluri_layout.dart';
|
||||
import '../../widgets/pluri_push_scaffold.dart';
|
||||
|
||||
/// EMISORAS group · "Emisoras personalizadas" (design ADR-3). Body moved
|
||||
/// verbatim from the former `_SeccionEmisoras` + `_FormularioEmisora` in
|
||||
/// verbatim from the former `_SeccionEmisoras` + `FormularioEmisoraPersonalizada` in
|
||||
/// `pantalla_ajustes.dart` — the panel header's icon, title and spacer were
|
||||
/// removed (the pushed screen's title now carries them); the "Add" action,
|
||||
/// being a real capability rather than decorative chrome, stays in the body,
|
||||
@@ -110,19 +110,21 @@ class _CuerpoEmisorasPersonalizadas extends StatelessWidget {
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
showDragHandle: true,
|
||||
builder: (ctx) => const _FormularioEmisora(),
|
||||
builder: (ctx) => const FormularioEmisoraPersonalizada(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FormularioEmisora extends StatefulWidget {
|
||||
const _FormularioEmisora();
|
||||
class FormularioEmisoraPersonalizada extends StatefulWidget {
|
||||
const FormularioEmisoraPersonalizada({super.key});
|
||||
|
||||
@override
|
||||
State<_FormularioEmisora> createState() => _FormularioEmisoraState();
|
||||
State<FormularioEmisoraPersonalizada> createState() =>
|
||||
FormularioEmisoraPersonalizadaState();
|
||||
}
|
||||
|
||||
class _FormularioEmisoraState extends State<_FormularioEmisora> {
|
||||
class FormularioEmisoraPersonalizadaState
|
||||
extends State<FormularioEmisoraPersonalizada> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _nombreCtrl = TextEditingController();
|
||||
final _urlCtrl = TextEditingController();
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../estado/estado_grabacion.dart';
|
||||
import '../../l10n/gen/app_localizations.dart';
|
||||
import '../../widgets/pluri_glass_surface.dart';
|
||||
import '../../widgets/pluri_layout.dart';
|
||||
import '../../widgets/pluri_push_scaffold.dart';
|
||||
|
||||
/// GRABACIONES Y MÚSICA group · "Grabaciones" (design ADR-3). Body moved
|
||||
/// 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});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return PluriPushScaffold(
|
||||
title: l10n.recordingsSectionTitle,
|
||||
body: ListView(
|
||||
padding: PluriLayout.pageContentPadding,
|
||||
children: const [_CuerpoGrabaciones()],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CuerpoGrabaciones extends StatelessWidget {
|
||||
const _CuerpoGrabaciones();
|
||||
|
||||
Future<void> _seleccionarRuta(BuildContext context) async {
|
||||
final estado = context.read<EstadoGrabacion>();
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final ruta = await FilePicker.platform.getDirectoryPath(
|
||||
dialogTitle: l10n.recordingsFolderDialogTitle,
|
||||
);
|
||||
if (ruta == null) return;
|
||||
try {
|
||||
await estado.cambiarDirectorio(ruta);
|
||||
if (!context.mounted) return;
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.recordingsPathUpdated)),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.recordingsPathSaveError(e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _restaurarRuta(BuildContext context) async {
|
||||
final estado = context.read<EstadoGrabacion>();
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final l10n = AppLocalizations.of(context);
|
||||
await estado.restaurarDirectorio();
|
||||
if (!context.mounted) return;
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.recordingsDefaultFolderRestored)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _abrirCarpeta(BuildContext context) async {
|
||||
final estado = context.read<EstadoGrabacion>();
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final l10n = AppLocalizations.of(context);
|
||||
try {
|
||||
final abierto = await estado.abrirDirectorio();
|
||||
if (!context.mounted) return;
|
||||
if (!abierto) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.recordingsOpenFolderError(l10n.dash))),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.recordingsOpenFolderError(e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
controller.dispose();
|
||||
if (nuevoMb == null || !context.mounted) return;
|
||||
await estado.cambiarMaxBytes(nuevoMb * 1024 * 1024);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l10n.recordingsMaxSizeSaved(nuevoMb))),
|
||||
);
|
||||
}
|
||||
|
||||
int _bytesAMegabytes(int bytes) =>
|
||||
(bytes / (1024 * 1024)).round().clamp(1, 1048576);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Recording state lives in EstadoGrabacion (S4-R2): this section only
|
||||
// rebuilds on recording changes, never on playback notifications.
|
||||
final estado = context.watch<EstadoGrabacion>();
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
return PluriGlassSurface(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
FutureBuilder<String>(
|
||||
future: estado.directorioEfectivo(),
|
||||
builder:
|
||||
(ctx, snap) => ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.folder_outlined),
|
||||
title: Text(l10n.recordingsFolderTitle),
|
||||
subtitle: Text(
|
||||
snap.data ?? l10n.recordingsPathCalculating,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(Icons.folder_open_rounded),
|
||||
label: Text(l10n.recordingsChangePath),
|
||||
onPressed: () => _seleccionarRuta(context),
|
||||
),
|
||||
FilledButton.tonalIcon(
|
||||
icon: const Icon(Icons.folder_copy_rounded),
|
||||
label: Text(l10n.recordingsOpenFolder),
|
||||
onPressed: () => _abrirCarpeta(context),
|
||||
),
|
||||
IconButton.filledTonal(
|
||||
tooltip: l10n.recordingsUseDefaultPath,
|
||||
icon: const Icon(Icons.restore_rounded),
|
||||
onPressed: () => _restaurarRuta(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.sd_storage_rounded),
|
||||
title: Text(l10n.recordingsMaxSizeTitle),
|
||||
subtitle: Text(
|
||||
l10n.recordingsMaxSizeSubtitle(_bytesAMegabytes(estado.maxBytes)),
|
||||
),
|
||||
onTap: () => _editarTamanoMaximo(context),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
l10n.recordingsOriginalStreamHint,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../estado/estado_idioma.dart';
|
||||
import '../../l10n/gen/app_localizations.dart';
|
||||
import '../../widgets/pluri_glass_surface.dart';
|
||||
import '../../widgets/pluri_layout.dart';
|
||||
import '../../widgets/pluri_push_scaffold.dart';
|
||||
|
||||
/// APLICACIÓN group · "Idioma" (design ADR-3). Body moved verbatim from the
|
||||
/// former `_SeccionIdioma` (+ `_IdiomaDisponible`) in `pantalla_ajustes.dart`
|
||||
/// — only the panel header's icon and title were removed (the pushed
|
||||
/// screen's title now carries them); every method and the language list
|
||||
/// below is unchanged.
|
||||
class PantallaAjustesIdioma extends StatelessWidget {
|
||||
const PantallaAjustesIdioma({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return PluriPushScaffold(
|
||||
title: l10n.languageSectionTitle,
|
||||
body: ListView(
|
||||
padding: PluriLayout.pageContentPadding,
|
||||
children: const [_CuerpoIdioma()],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CuerpoIdioma extends StatelessWidget {
|
||||
const _CuerpoIdioma();
|
||||
|
||||
static const _codigoSistema = 'system';
|
||||
static const _idiomas = [
|
||||
_IdiomaDisponible(Locale('en'), 'English'),
|
||||
_IdiomaDisponible(Locale('es'), 'Español'),
|
||||
_IdiomaDisponible(Locale('zh'), '中文'),
|
||||
_IdiomaDisponible(Locale('hi'), 'हिन्दी'),
|
||||
_IdiomaDisponible(Locale('ar'), 'العربية'),
|
||||
_IdiomaDisponible(Locale('pt'), 'Português'),
|
||||
_IdiomaDisponible(Locale('fr'), 'Français'),
|
||||
_IdiomaDisponible(Locale('ru'), 'Русский'),
|
||||
_IdiomaDisponible(Locale('de'), 'Deutsch'),
|
||||
_IdiomaDisponible(Locale('ja'), '日本語'),
|
||||
_IdiomaDisponible(Locale('id'), 'Bahasa Indonesia'),
|
||||
_IdiomaDisponible(Locale('bn'), 'বাংলা'),
|
||||
_IdiomaDisponible(Locale('it'), 'Italiano'),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final estadoIdioma = context.watch<EstadoIdioma>();
|
||||
final locale = estadoIdioma.localeSeleccionado;
|
||||
final valorActual = locale == null ? _codigoSistema : _codigoLocale(locale);
|
||||
|
||||
return PluriGlassSurface(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
l10n.languageSectionDescription,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: valorActual,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.languageSectionTitle,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
items: [
|
||||
DropdownMenuItem(
|
||||
value: _codigoSistema,
|
||||
child: Text(l10n.languageSystemDefault),
|
||||
),
|
||||
for (final idioma in _idiomas)
|
||||
DropdownMenuItem(
|
||||
value: _codigoLocale(idioma.locale),
|
||||
child: Text(idioma.nombreNativo),
|
||||
),
|
||||
],
|
||||
onChanged: (codigo) async {
|
||||
if (codigo == null) return;
|
||||
if (codigo == _codigoSistema) {
|
||||
await context.read<EstadoIdioma>().seleccionarSistema();
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l10n.languageUpdatedSystem)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final idioma = _idiomas.firstWhere(
|
||||
(item) => _codigoLocale(item.locale) == codigo,
|
||||
orElse: () => _idiomas.first,
|
||||
);
|
||||
await context.read<EstadoIdioma>().seleccionarLocale(
|
||||
idioma.locale,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(l10n.languageUpdated(idioma.nombreNativo)),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String _codigoLocale(Locale locale) {
|
||||
final countryCode = locale.countryCode;
|
||||
if (countryCode == null || countryCode.isEmpty) {
|
||||
return locale.languageCode;
|
||||
}
|
||||
return '${locale.languageCode}_$countryCode';
|
||||
}
|
||||
}
|
||||
|
||||
class _IdiomaDisponible {
|
||||
const _IdiomaDisponible(this.locale, this.nombreNativo);
|
||||
|
||||
final Locale locale;
|
||||
final String nombreNativo;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../estado/estado_radio.dart';
|
||||
import '../../l10n/gen/app_localizations.dart';
|
||||
import '../../widgets/pluri_glass_surface.dart';
|
||||
import '../../widgets/pluri_icon.dart';
|
||||
import '../../widgets/pluri_layout.dart';
|
||||
import '../../widgets/pluri_onboarding_dialog.dart';
|
||||
import '../../widgets/pluri_push_scaffold.dart';
|
||||
|
||||
/// APLICACIÓN group · "Info" (design ADR-3). Body moved verbatim from the
|
||||
/// former `_SeccionInfo` in `pantalla_ajustes.dart`. Unlike the other four
|
||||
/// sections in this batch, `_SeccionInfo` never had its own header
|
||||
/// icon+title row — its first tile (app name + version) already served that
|
||||
/// role — so there is no header row to strip here; the body below is
|
||||
/// unchanged in full. `infoSectionTitle` is the one new ARB key this screen
|
||||
/// needed, since no existing in-body header string covers a bare "Info"
|
||||
/// label (see WU3b's apply-progress note).
|
||||
class PantallaAjustesInfo extends StatelessWidget {
|
||||
const PantallaAjustesInfo({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return PluriPushScaffold(
|
||||
title: l10n.infoSectionTitle,
|
||||
body: ListView(
|
||||
padding: PluriLayout.pageContentPadding,
|
||||
children: const [_CuerpoInfo()],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CuerpoInfo extends StatelessWidget {
|
||||
const _CuerpoInfo();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<EstadoRadio>(
|
||||
builder:
|
||||
(ctx, estado, _) => PluriGlassSurface(
|
||||
child: Column(
|
||||
children: [
|
||||
FutureBuilder<PackageInfo>(
|
||||
future: PackageInfo.fromPlatform(),
|
||||
builder: (ctx, snap) {
|
||||
final version =
|
||||
snap.hasData
|
||||
? 'v${snap.data!.version}+${snap.data!.buildNumber}'
|
||||
: AppLocalizations.of(ctx).appVersionLoading;
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const PluriIcon(
|
||||
glyph: PluriIconGlyph.settings,
|
||||
variant: PluriIconVariant.filled,
|
||||
),
|
||||
title: Text(AppLocalizations.of(ctx).appTitle),
|
||||
subtitle: Text(
|
||||
AppLocalizations.of(ctx).appVersionSubtitle(version),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
FutureBuilder<int>(
|
||||
future: estado.favoritos.obtenerTodos().then((l) => l.length),
|
||||
builder:
|
||||
(ctx, snap) => ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.favorite_outline_rounded),
|
||||
title: Text(
|
||||
AppLocalizations.of(ctx).savedFavoritesTitle,
|
||||
),
|
||||
trailing: Text(
|
||||
snap.data?.toString() ??
|
||||
AppLocalizations.of(ctx).dash,
|
||||
style: Theme.of(ctx).textTheme.bodyLarge,
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.help_outline_rounded),
|
||||
title: Text(AppLocalizations.of(ctx).helpTitle),
|
||||
subtitle: Text(AppLocalizations.of(ctx).helpSubtitle),
|
||||
trailing: const Icon(Icons.chevron_right_rounded),
|
||||
onTap: () => PluriOnboardingDialog.mostrar(ctx),
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.verified_outlined),
|
||||
title: Text(AppLocalizations.of(ctx).stationFilterTitle),
|
||||
subtitle: Text(
|
||||
AppLocalizations.of(ctx).stationFilterSubtitle,
|
||||
),
|
||||
trailing: Icon(
|
||||
Icons.check_circle_rounded,
|
||||
color: Theme.of(ctx).colorScheme.secondary,
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.music_note_outlined),
|
||||
title: Text(AppLocalizations.of(ctx).backgroundAudioTitle),
|
||||
subtitle: Text(
|
||||
AppLocalizations.of(ctx).backgroundAudioSubtitle,
|
||||
),
|
||||
trailing: Icon(
|
||||
Icons.check_circle_rounded,
|
||||
color: Theme.of(ctx).colorScheme.secondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../l10n/gen/app_localizations.dart';
|
||||
import '../../servicios/musica_local_auto.dart';
|
||||
import '../../widgets/pluri_glass_surface.dart';
|
||||
import '../../widgets/pluri_layout.dart';
|
||||
import '../../widgets/pluri_push_scaffold.dart';
|
||||
|
||||
/// GRABACIONES Y MÚSICA group · "Música local" (design ADR-3). Body moved
|
||||
/// verbatim from the former `_SeccionMusicaLocal` in `pantalla_ajustes.dart`
|
||||
/// — only the panel header's icon and title were removed (the pushed
|
||||
/// screen's title now carries them). Deliberately does NOT use
|
||||
/// `FilePicker.platform` (see tasks.md "Grounding corrections") —
|
||||
/// [FuenteMusicaLocalAutoImpl.elegirCarpeta] calls the native
|
||||
/// `pickMusicFolder` channel method directly, since it needs a
|
||||
/// persistable-grant SAF tree URI, not a plain filesystem path.
|
||||
class PantallaAjustesMusicaLocal extends StatelessWidget {
|
||||
const PantallaAjustesMusicaLocal({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return PluriPushScaffold(
|
||||
title: l10n.localMusicSectionTitle,
|
||||
body: ListView(
|
||||
padding: PluriLayout.pageContentPadding,
|
||||
children: const [_CuerpoMusicaLocal()],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CuerpoMusicaLocal extends StatefulWidget {
|
||||
const _CuerpoMusicaLocal();
|
||||
|
||||
@override
|
||||
State<_CuerpoMusicaLocal> createState() => _CuerpoMusicaLocalState();
|
||||
}
|
||||
|
||||
class _CuerpoMusicaLocalState extends State<_CuerpoMusicaLocal> {
|
||||
final _fuente = FuenteMusicaLocalAutoImpl();
|
||||
late Future<String?> _carpetaActual;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_carpetaActual = _fuente.carpetaActual();
|
||||
}
|
||||
|
||||
Future<void> _elegirCarpeta(BuildContext context) async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final l10n = AppLocalizations.of(context);
|
||||
try {
|
||||
final uri = await _fuente.elegirCarpeta();
|
||||
if (uri == null) return; // Cancelled — no snackbar, matches the SAF
|
||||
// picker's own "nothing changed" affordance.
|
||||
if (!context.mounted) return;
|
||||
setState(() {
|
||||
_carpetaActual = Future.value(uri);
|
||||
});
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.localMusicFolderUpdated)),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.localMusicFolderSaveError(e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
return PluriGlassSurface(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
l10n.localMusicSectionDescription,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
FutureBuilder<String?>(
|
||||
future: _carpetaActual,
|
||||
builder: (ctx, snap) {
|
||||
final carpeta = snap.data;
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.folder_outlined),
|
||||
title: Text(l10n.localMusicFolderTitle),
|
||||
subtitle: Text(
|
||||
(carpeta == null || carpeta.isEmpty)
|
||||
? l10n.localMusicFolderNotConfigured
|
||||
: nombreCarpetaDesdeUri(
|
||||
carpeta,
|
||||
nombreGenerico: l10n.localMusicFolderGenericName,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
FutureBuilder<String?>(
|
||||
future: _carpetaActual,
|
||||
builder: (ctx, snap) {
|
||||
final configurada = (snap.data ?? '').isNotEmpty;
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: OutlinedButton.icon(
|
||||
icon: const Icon(Icons.folder_open_rounded),
|
||||
label: Text(
|
||||
configurada
|
||||
? l10n.localMusicChangePath
|
||||
: l10n.localMusicChoosePath,
|
||||
),
|
||||
onPressed: () => _elegirCarpeta(context),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,23 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:share_plus/share_plus.dart' show Share, XFile;
|
||||
|
||||
import '../estado/estado_grabacion.dart';
|
||||
import '../estado/estado_idioma.dart';
|
||||
import '../estado/estado_radio.dart';
|
||||
import '../l10n/gen/app_localizations.dart';
|
||||
import '../servicios/musica_local_auto.dart';
|
||||
import '../widgets/pluri_glass_surface.dart';
|
||||
import '../widgets/pluri_icon.dart';
|
||||
import '../widgets/pluri_layout.dart';
|
||||
import '../widgets/pluri_onboarding_dialog.dart';
|
||||
import '../widgets/pluri_premium_widgets.dart';
|
||||
import '../widgets/pluri_push_scaffold.dart';
|
||||
import 'ajustes/pantalla_ajustes_backup.dart';
|
||||
import 'ajustes/pantalla_ajustes_ecualizador.dart';
|
||||
import 'ajustes/pantalla_ajustes_emisora_preferida.dart';
|
||||
import 'ajustes/pantalla_ajustes_emisoras_personalizadas.dart';
|
||||
import 'ajustes/pantalla_ajustes_grupos_favoritos.dart';
|
||||
import 'ajustes/pantalla_ajustes_idioma.dart';
|
||||
import 'ajustes/pantalla_ajustes_info.dart';
|
||||
import 'ajustes/pantalla_ajustes_musica_local.dart';
|
||||
import 'ajustes/pantalla_ajustes_orden_listas.dart';
|
||||
import 'ajustes/pantalla_ajustes_salida_audio.dart';
|
||||
import 'ajustes/pantalla_ajustes_timer_sueno.dart';
|
||||
import 'ajustes/widgets/fila_ajuste.dart';
|
||||
import 'pantalla_grabaciones.dart';
|
||||
|
||||
class PantallaAjustes extends StatelessWidget {
|
||||
const PantallaAjustes({super.key});
|
||||
@@ -56,13 +47,11 @@ class PantallaAjustes extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Design ADR-3: the AUDIO and EMISORAS groups are grouped nav rows only —
|
||||
/// each [FilaAjuste] pushes its own detail screen via
|
||||
/// `PluriPushScaffold.push`, carrying zero inline controls in the root.
|
||||
///
|
||||
/// The remaining sections (GRABACIONES Y MÚSICA, APLICACIÓN) still render
|
||||
/// inline here pending WU3b, which decomposes them the same way. Sleep timer
|
||||
/// and backup/restore stay reachable throughout — nothing is dropped.
|
||||
/// Design ADR-3: the Settings root is grouped nav rows only — each
|
||||
/// [FilaAjuste] pushes its own detail screen via `PluriPushScaffold.push`,
|
||||
/// carrying zero inline controls. All 12 sections are now decomposed across
|
||||
/// WU3a (AUDIO, EMISORAS) and WU3b (GRABACIONES Y MÚSICA, APLICACIÓN); sleep
|
||||
/// timer and backup/restore stay reachable throughout — nothing was dropped.
|
||||
class _AjustesContent extends StatelessWidget {
|
||||
const _AjustesContent();
|
||||
|
||||
@@ -147,642 +136,68 @@ class _AjustesContent extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const _SeccionGrabaciones(),
|
||||
GrupoAjustes(
|
||||
titulo: l10n.settingsGroupRecordingsTitle,
|
||||
filas: [
|
||||
FilaAjuste(
|
||||
icon: Icons.radio_button_checked_rounded,
|
||||
titulo: l10n.recordingsSectionTitle,
|
||||
// WU15b: this row opens the recordings LIBRARY
|
||||
// (PantallaGrabaciones), matching the approved mockup's
|
||||
// "Ajustes > Grabaciones" screen. The folder/size settings
|
||||
// form (PantallaAjustesGrabaciones) is still reachable, but
|
||||
// now from within the library via its own settings action.
|
||||
onTap:
|
||||
() => PluriPushScaffold.push(
|
||||
context,
|
||||
(_) => const PantallaGrabaciones(),
|
||||
),
|
||||
),
|
||||
FilaAjuste(
|
||||
icon: Icons.library_music_outlined,
|
||||
titulo: l10n.localMusicSectionTitle,
|
||||
onTap:
|
||||
() => PluriPushScaffold.push(
|
||||
context,
|
||||
(_) => const PantallaAjustesMusicaLocal(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const _SeccionMusicaLocal(),
|
||||
const SizedBox(height: 12),
|
||||
const _SeccionIdioma(),
|
||||
const SizedBox(height: 12),
|
||||
const _SeccionBackup(),
|
||||
const SizedBox(height: 12),
|
||||
const _SeccionInfo(),
|
||||
GrupoAjustes(
|
||||
titulo: l10n.settingsGroupApplicationTitle,
|
||||
filas: [
|
||||
FilaAjuste(
|
||||
icon: Icons.language_rounded,
|
||||
titulo: l10n.languageSectionTitle,
|
||||
onTap:
|
||||
() => PluriPushScaffold.push(
|
||||
context,
|
||||
(_) => const PantallaAjustesIdioma(),
|
||||
),
|
||||
),
|
||||
FilaAjuste(
|
||||
icon: Icons.backup_outlined,
|
||||
titulo: l10n.backupSectionTitle,
|
||||
onTap:
|
||||
() => PluriPushScaffold.push(
|
||||
context,
|
||||
(_) => const PantallaAjustesBackup(),
|
||||
),
|
||||
),
|
||||
FilaAjuste(
|
||||
icon: Icons.info_outline_rounded,
|
||||
titulo: l10n.infoSectionTitle,
|
||||
onTap:
|
||||
() => PluriPushScaffold.push(
|
||||
context,
|
||||
(_) => const PantallaAjustesInfo(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SeccionGrabaciones extends StatelessWidget {
|
||||
const _SeccionGrabaciones();
|
||||
|
||||
Future<void> _seleccionarRuta(BuildContext context) async {
|
||||
final estado = context.read<EstadoGrabacion>();
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final ruta = await FilePicker.platform.getDirectoryPath(
|
||||
dialogTitle: l10n.recordingsFolderDialogTitle,
|
||||
);
|
||||
if (ruta == null) return;
|
||||
try {
|
||||
await estado.cambiarDirectorio(ruta);
|
||||
if (!context.mounted) return;
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.recordingsPathUpdated)),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.recordingsPathSaveError(e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _restaurarRuta(BuildContext context) async {
|
||||
final estado = context.read<EstadoGrabacion>();
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final l10n = AppLocalizations.of(context);
|
||||
await estado.restaurarDirectorio();
|
||||
if (!context.mounted) return;
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.recordingsDefaultFolderRestored)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _abrirCarpeta(BuildContext context) async {
|
||||
final estado = context.read<EstadoGrabacion>();
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final l10n = AppLocalizations.of(context);
|
||||
try {
|
||||
final abierto = await estado.abrirDirectorio();
|
||||
if (!context.mounted) return;
|
||||
if (!abierto) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.recordingsOpenFolderError(l10n.dash))),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.recordingsOpenFolderError(e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
controller.dispose();
|
||||
if (nuevoMb == null || !context.mounted) return;
|
||||
await estado.cambiarMaxBytes(nuevoMb * 1024 * 1024);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l10n.recordingsMaxSizeSaved(nuevoMb))),
|
||||
);
|
||||
}
|
||||
|
||||
int _bytesAMegabytes(int bytes) =>
|
||||
(bytes / (1024 * 1024)).round().clamp(1, 1048576);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Recording state lives in EstadoGrabacion (S4-R2): this section only
|
||||
// rebuilds on recording changes, never on playback notifications.
|
||||
final estado = context.watch<EstadoGrabacion>();
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
return PluriGlassSurface(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.radio_button_checked_rounded),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
l10n.recordingsSectionTitle,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
FutureBuilder<String>(
|
||||
future: estado.directorioEfectivo(),
|
||||
builder:
|
||||
(ctx, snap) => ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.folder_outlined),
|
||||
title: Text(l10n.recordingsFolderTitle),
|
||||
subtitle: Text(
|
||||
snap.data ?? l10n.recordingsPathCalculating,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(Icons.folder_open_rounded),
|
||||
label: Text(l10n.recordingsChangePath),
|
||||
onPressed: () => _seleccionarRuta(context),
|
||||
),
|
||||
FilledButton.tonalIcon(
|
||||
icon: const Icon(Icons.folder_copy_rounded),
|
||||
label: Text(l10n.recordingsOpenFolder),
|
||||
onPressed: () => _abrirCarpeta(context),
|
||||
),
|
||||
IconButton.filledTonal(
|
||||
tooltip: l10n.recordingsUseDefaultPath,
|
||||
icon: const Icon(Icons.restore_rounded),
|
||||
onPressed: () => _restaurarRuta(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.sd_storage_rounded),
|
||||
title: Text(l10n.recordingsMaxSizeTitle),
|
||||
subtitle: Text(
|
||||
l10n.recordingsMaxSizeSubtitle(_bytesAMegabytes(estado.maxBytes)),
|
||||
),
|
||||
onTap: () => _editarTamanoMaximo(context),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
l10n.recordingsOriginalStreamHint,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Local-music root-folder picker (android-auto-local-music task 9),
|
||||
/// mirroring [_SeccionGrabaciones]'s shape: `PluriGlassSurface` card,
|
||||
/// `FutureBuilder`-driven current-folder display, a single action button and
|
||||
/// snackbar feedback. Deliberately does NOT use `FilePicker.platform` (see
|
||||
/// tasks.md "Grounding corrections") — [FuenteMusicaLocalAutoImpl.elegirCarpeta]
|
||||
/// calls the NEW native `pickMusicFolder` channel method directly, since it
|
||||
/// needs a persistable-grant SAF tree URI, not a plain filesystem path.
|
||||
class _SeccionMusicaLocal extends StatefulWidget {
|
||||
const _SeccionMusicaLocal();
|
||||
|
||||
@override
|
||||
State<_SeccionMusicaLocal> createState() => _SeccionMusicaLocalState();
|
||||
}
|
||||
|
||||
class _SeccionMusicaLocalState extends State<_SeccionMusicaLocal> {
|
||||
final _fuente = FuenteMusicaLocalAutoImpl();
|
||||
late Future<String?> _carpetaActual;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_carpetaActual = _fuente.carpetaActual();
|
||||
}
|
||||
|
||||
Future<void> _elegirCarpeta(BuildContext context) async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final l10n = AppLocalizations.of(context);
|
||||
try {
|
||||
final uri = await _fuente.elegirCarpeta();
|
||||
if (uri == null) return; // Cancelled — no snackbar, matches the SAF
|
||||
// picker's own "nothing changed" affordance.
|
||||
if (!context.mounted) return;
|
||||
setState(() {
|
||||
_carpetaActual = Future.value(uri);
|
||||
});
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.localMusicFolderUpdated)),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.localMusicFolderSaveError(e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
return PluriGlassSurface(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.library_music_outlined),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
l10n.localMusicSectionTitle,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
l10n.localMusicSectionDescription,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
FutureBuilder<String?>(
|
||||
future: _carpetaActual,
|
||||
builder: (ctx, snap) {
|
||||
final carpeta = snap.data;
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.folder_outlined),
|
||||
title: Text(l10n.localMusicFolderTitle),
|
||||
subtitle: Text(
|
||||
(carpeta == null || carpeta.isEmpty)
|
||||
? l10n.localMusicFolderNotConfigured
|
||||
: nombreCarpetaDesdeUri(
|
||||
carpeta,
|
||||
nombreGenerico: l10n.localMusicFolderGenericName,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
FutureBuilder<String?>(
|
||||
future: _carpetaActual,
|
||||
builder: (ctx, snap) {
|
||||
final configurada = (snap.data ?? '').isNotEmpty;
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: OutlinedButton.icon(
|
||||
icon: const Icon(Icons.folder_open_rounded),
|
||||
label: Text(
|
||||
configurada
|
||||
? l10n.localMusicChangePath
|
||||
: l10n.localMusicChoosePath,
|
||||
),
|
||||
onPressed: () => _elegirCarpeta(context),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SeccionIdioma extends StatelessWidget {
|
||||
const _SeccionIdioma();
|
||||
|
||||
static const _codigoSistema = 'system';
|
||||
static const _idiomas = [
|
||||
_IdiomaDisponible(Locale('en'), 'English'),
|
||||
_IdiomaDisponible(Locale('es'), 'Español'),
|
||||
_IdiomaDisponible(Locale('zh'), '中文'),
|
||||
_IdiomaDisponible(Locale('hi'), 'हिन्दी'),
|
||||
_IdiomaDisponible(Locale('ar'), 'العربية'),
|
||||
_IdiomaDisponible(Locale('pt'), 'Português'),
|
||||
_IdiomaDisponible(Locale('fr'), 'Français'),
|
||||
_IdiomaDisponible(Locale('ru'), 'Русский'),
|
||||
_IdiomaDisponible(Locale('de'), 'Deutsch'),
|
||||
_IdiomaDisponible(Locale('ja'), '日本語'),
|
||||
_IdiomaDisponible(Locale('id'), 'Bahasa Indonesia'),
|
||||
_IdiomaDisponible(Locale('bn'), 'বাংলা'),
|
||||
_IdiomaDisponible(Locale('it'), 'Italiano'),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final estadoIdioma = context.watch<EstadoIdioma>();
|
||||
final locale = estadoIdioma.localeSeleccionado;
|
||||
final valorActual = locale == null ? _codigoSistema : _codigoLocale(locale);
|
||||
|
||||
return PluriGlassSurface(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.language_rounded),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
l10n.languageSectionTitle,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
l10n.languageSectionDescription,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: valorActual,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.languageSectionTitle,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
items: [
|
||||
DropdownMenuItem(
|
||||
value: _codigoSistema,
|
||||
child: Text(l10n.languageSystemDefault),
|
||||
),
|
||||
for (final idioma in _idiomas)
|
||||
DropdownMenuItem(
|
||||
value: _codigoLocale(idioma.locale),
|
||||
child: Text(idioma.nombreNativo),
|
||||
),
|
||||
],
|
||||
onChanged: (codigo) async {
|
||||
if (codigo == null) return;
|
||||
if (codigo == _codigoSistema) {
|
||||
await context.read<EstadoIdioma>().seleccionarSistema();
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l10n.languageUpdatedSystem)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final idioma = _idiomas.firstWhere(
|
||||
(item) => _codigoLocale(item.locale) == codigo,
|
||||
orElse: () => _idiomas.first,
|
||||
);
|
||||
await context.read<EstadoIdioma>().seleccionarLocale(
|
||||
idioma.locale,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(l10n.languageUpdated(idioma.nombreNativo)),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String _codigoLocale(Locale locale) {
|
||||
final countryCode = locale.countryCode;
|
||||
if (countryCode == null || countryCode.isEmpty) {
|
||||
return locale.languageCode;
|
||||
}
|
||||
return '${locale.languageCode}_$countryCode';
|
||||
}
|
||||
}
|
||||
|
||||
class _IdiomaDisponible {
|
||||
const _IdiomaDisponible(this.locale, this.nombreNativo);
|
||||
|
||||
final Locale locale;
|
||||
final String nombreNativo;
|
||||
}
|
||||
|
||||
class _SeccionBackup extends StatelessWidget {
|
||||
const _SeccionBackup();
|
||||
|
||||
Future<void> _exportar(BuildContext context) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
try {
|
||||
final estado = context.read<EstadoRadio>();
|
||||
// JSON serialization is owned by ServicioExportImport (S4-R4).
|
||||
final json = await estado.exportarConfigJson();
|
||||
|
||||
final dir = await getTemporaryDirectory();
|
||||
final file = File('${dir.path}/pluriwave-backup.json');
|
||||
await file.writeAsString(json);
|
||||
|
||||
await Share.shareXFiles(
|
||||
[XFile(file.path)],
|
||||
subject: l10n.backupShareSubject,
|
||||
text: l10n.backupShareText(DateTime.now().toLocal()),
|
||||
);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l10n.backupExportError(e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _importar(BuildContext context) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
try {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['json'],
|
||||
);
|
||||
if (result == null || result.files.single.path == null) return;
|
||||
|
||||
final file = File(result.files.single.path!);
|
||||
final contenido = await file.readAsString();
|
||||
if (!context.mounted) return;
|
||||
// Parsing is owned by ServicioExportImport (S4-R4): null = malformed.
|
||||
final json = context.read<EstadoRadio>().parsearConfigJson(contenido);
|
||||
if (json == null) {
|
||||
throw const FormatException('invalid backup file');
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
final confirmar = await showDialog<bool>(
|
||||
context: context,
|
||||
builder:
|
||||
(ctx) => AlertDialog(
|
||||
title: Text(AppLocalizations.of(ctx).backupImportTitle),
|
||||
content: Text(
|
||||
AppLocalizations.of(ctx).backupImportConfirmMessage,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: Text(AppLocalizations.of(ctx).cancelAction),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: Text(AppLocalizations.of(ctx).backupImportTitle),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmar != true) return;
|
||||
if (context.mounted) {
|
||||
final estado = context.read<EstadoRadio>();
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
await estado.importarConfig(json);
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.backupImportSuccess)),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l10n.backupImportError(e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PluriGlassSurface(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.backup_outlined),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
AppLocalizations.of(context).backupSectionTitle,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.upload_outlined),
|
||||
title: Text(AppLocalizations.of(context).backupExportTitle),
|
||||
subtitle: Text(AppLocalizations.of(context).backupExportSubtitle),
|
||||
onTap: () => _exportar(context),
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.download_outlined),
|
||||
title: Text(AppLocalizations.of(context).backupImportTitle),
|
||||
subtitle: Text(AppLocalizations.of(context).backupImportSubtitle),
|
||||
onTap: () => _importar(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SeccionInfo extends StatelessWidget {
|
||||
const _SeccionInfo();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<EstadoRadio>(
|
||||
builder:
|
||||
(ctx, estado, _) => PluriGlassSurface(
|
||||
child: Column(
|
||||
children: [
|
||||
FutureBuilder<PackageInfo>(
|
||||
future: PackageInfo.fromPlatform(),
|
||||
builder: (ctx, snap) {
|
||||
final version =
|
||||
snap.hasData
|
||||
? 'v${snap.data!.version}+${snap.data!.buildNumber}'
|
||||
: AppLocalizations.of(ctx).appVersionLoading;
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const PluriIcon(
|
||||
glyph: PluriIconGlyph.settings,
|
||||
variant: PluriIconVariant.filled,
|
||||
),
|
||||
title: Text(AppLocalizations.of(ctx).appTitle),
|
||||
subtitle: Text(
|
||||
AppLocalizations.of(ctx).appVersionSubtitle(version),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
FutureBuilder<int>(
|
||||
future: estado.favoritos.obtenerTodos().then((l) => l.length),
|
||||
builder:
|
||||
(ctx, snap) => ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.favorite_outline_rounded),
|
||||
title: Text(
|
||||
AppLocalizations.of(ctx).savedFavoritesTitle,
|
||||
),
|
||||
trailing: Text(
|
||||
snap.data?.toString() ??
|
||||
AppLocalizations.of(ctx).dash,
|
||||
style: Theme.of(ctx).textTheme.bodyLarge,
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.help_outline_rounded),
|
||||
title: Text(AppLocalizations.of(ctx).helpTitle),
|
||||
subtitle: Text(AppLocalizations.of(ctx).helpSubtitle),
|
||||
trailing: const Icon(Icons.chevron_right_rounded),
|
||||
onTap: () => PluriOnboardingDialog.mostrar(ctx),
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.verified_outlined),
|
||||
title: Text(AppLocalizations.of(ctx).stationFilterTitle),
|
||||
subtitle: Text(
|
||||
AppLocalizations.of(ctx).stationFilterSubtitle,
|
||||
),
|
||||
trailing: Icon(
|
||||
Icons.check_circle_rounded,
|
||||
color: Theme.of(ctx).colorScheme.secondary,
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.music_note_outlined),
|
||||
title: Text(AppLocalizations.of(ctx).backgroundAudioTitle),
|
||||
subtitle: Text(
|
||||
AppLocalizations.of(ctx).backgroundAudioSubtitle,
|
||||
),
|
||||
trailing: Icon(
|
||||
Icons.check_circle_rounded,
|
||||
color: Theme.of(ctx).colorScheme.secondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,24 +6,91 @@ import '../l10n/display_names.dart';
|
||||
import '../l10n/gen/app_localizations.dart';
|
||||
import '../modelos/emisora.dart';
|
||||
import '../modelos/grupo_favoritos.dart';
|
||||
import '../widgets/pluri_glass_surface.dart';
|
||||
import '../widgets/pluri_icon.dart';
|
||||
import '../widgets/pluri_layout.dart';
|
||||
import '../widgets/pluri_premium_widgets.dart';
|
||||
import '../widgets/pluri_push_scaffold.dart';
|
||||
import 'package:pluriwave/widgets/tarjeta_emisora.dart';
|
||||
import 'ajustes/pantalla_ajustes_emisoras_personalizadas.dart';
|
||||
import 'ajustes/pantalla_ajustes_grupos_favoritos.dart';
|
||||
|
||||
import 'reproducir_minimizado.dart';
|
||||
|
||||
class PantallaFavoritos extends StatelessWidget {
|
||||
/// WU4, `favorites-organization` spec: a chip-filtered flat list replacing
|
||||
/// the previous stacked per-group panels, with drag-to-reorder, a `swap_vert`
|
||||
/// sort action, group management, and the custom-station CTA all reachable
|
||||
/// from this root screen. Favoritos is the one root that keeps its bottom
|
||||
/// tab bar (design ADR-2's documented exemption) — this file constructs no
|
||||
/// `PluriPushScaffold` and stays a plain body widget for that reason.
|
||||
class PantallaFavoritos extends StatefulWidget {
|
||||
const PantallaFavoritos({super.key});
|
||||
|
||||
@override
|
||||
State<PantallaFavoritos> createState() => _PantallaFavoritosState();
|
||||
}
|
||||
|
||||
class _PantallaFavoritosState extends State<PantallaFavoritos> {
|
||||
/// Ephemeral UI state only (design's "State is for ephemeral UI only"
|
||||
/// ruling) — null means the "All" chip is active.
|
||||
String? _grupoSeleccionadoId;
|
||||
|
||||
Future<void> _abrirFormularioEmisoraPersonalizada() async {
|
||||
await showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
showDragHandle: true,
|
||||
builder: (ctx) => const FormularioEmisoraPersonalizada(),
|
||||
);
|
||||
}
|
||||
|
||||
void _abrirGestionDeListas() {
|
||||
PluriPushScaffold.push(
|
||||
context,
|
||||
(_) => const PantallaAjustesGruposFavoritos(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _elegirOrden(OrdenEmisoras criterio) =>
|
||||
context.read<EstadoRadio>().ordenarFavoritos(criterio);
|
||||
|
||||
/// Translates a drag within the currently FILTERED view into the absolute
|
||||
/// global position [EstadoRadio.reordenarFavorito] expects, so a drag
|
||||
/// while a group chip is active still produces a coherent global order
|
||||
/// (other groups' relative order is left untouched).
|
||||
void _onReorder(
|
||||
List<Emisora> filtrados,
|
||||
List<Emisora> favoritos,
|
||||
int oldIndex,
|
||||
int newIndex,
|
||||
) {
|
||||
final movido = filtrados[oldIndex];
|
||||
final restantes = List<Emisora>.from(filtrados)..removeAt(oldIndex);
|
||||
final int nuevoIndiceGlobal;
|
||||
if (restantes.isEmpty) {
|
||||
nuevoIndiceGlobal = favoritos.length - 1;
|
||||
} else if (newIndex >= restantes.length) {
|
||||
nuevoIndiceGlobal = favoritos.indexWhere(
|
||||
(e) => e.uuid == restantes.last.uuid,
|
||||
);
|
||||
} else {
|
||||
nuevoIndiceGlobal = favoritos.indexWhere(
|
||||
(e) => e.uuid == restantes[newIndex].uuid,
|
||||
);
|
||||
}
|
||||
context.read<EstadoRadio>().reordenarFavorito(
|
||||
movido.uuid,
|
||||
nuevoIndiceGlobal,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// S4-R5: no root watch — select only the fields this screen reads. The
|
||||
// getters are identity-memoized, so playback notifications that do not
|
||||
// change favorites/groups no longer rebuild the screen.
|
||||
final favoritos = context.select<EstadoRadio, List<Emisora>>(
|
||||
(e) => e.listaFavoritos,
|
||||
(e) => e.listaFavoritosManual,
|
||||
);
|
||||
final grupos = context.select<EstadoRadio, List<GrupoFavoritos>>(
|
||||
(e) => e.gruposFavoritos,
|
||||
@@ -51,6 +118,12 @@ class PantallaFavoritos extends StatelessWidget {
|
||||
subtitle: l10n.favoritesEmptySubtitle,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: PluriLayout.pageContentPadding,
|
||||
child: _CtaEmisoraPersonalizada(
|
||||
onTap: _abrirFormularioEmisoraPersonalizada,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -67,57 +140,117 @@ class PantallaFavoritos extends StatelessWidget {
|
||||
]
|
||||
: grupos;
|
||||
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: PluriScreenHeader(
|
||||
title: l10n.favoritesTitle,
|
||||
subtitle: l10n.favoritesHeaderSubtitle,
|
||||
glyph: PluriIconGlyph.favorites,
|
||||
trailing: PluriStatusPill(
|
||||
icon: Icons.library_music_rounded,
|
||||
label: l10n.favoritesSavedCount(favoritos.length),
|
||||
// Defensive: a group selected before it was deleted elsewhere (e.g. via
|
||||
// the pushed management screen) falls back to "All" instead of showing
|
||||
// an empty list with no chip highlighted.
|
||||
final seleccionEfectiva =
|
||||
gruposVisibles.any((g) => g.id == _grupoSeleccionadoId)
|
||||
? _grupoSeleccionadoId
|
||||
: null;
|
||||
|
||||
final filtrados =
|
||||
seleccionEfectiva == null
|
||||
? favoritos
|
||||
: favoritos
|
||||
.where((e) => e.grupoFavoritosId == seleccionEfectiva)
|
||||
.toList();
|
||||
|
||||
return ReorderableListView(
|
||||
buildDefaultDragHandles: false,
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
PluriLayout.horizontal,
|
||||
4,
|
||||
PluriLayout.horizontal,
|
||||
PluriLayout.bottomChromeInset,
|
||||
),
|
||||
header: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
PluriScreenHeader(
|
||||
title: l10n.favoritesTitle,
|
||||
subtitle: l10n.favoritesHeaderSubtitle,
|
||||
glyph: PluriIconGlyph.favorites,
|
||||
trailing: PluriStatusPill(
|
||||
icon: Icons.library_music_rounded,
|
||||
label: l10n.favoritesSavedCount(favoritos.length),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _FilaChipsGrupos(
|
||||
grupos: gruposVisibles,
|
||||
favoritos: favoritos,
|
||||
seleccionado: seleccionEfectiva,
|
||||
onSeleccionar:
|
||||
(id) => setState(() => _grupoSeleccionadoId = id),
|
||||
onGestionar: _abrirGestionDeListas,
|
||||
),
|
||||
),
|
||||
PopupMenuButton<OrdenEmisoras>(
|
||||
icon: const Icon(Icons.swap_vert_rounded),
|
||||
tooltip: l10n.stationOrderTitle,
|
||||
onSelected: _elegirOrden,
|
||||
itemBuilder:
|
||||
(context) => [
|
||||
PopupMenuItem(
|
||||
value: OrdenEmisoras.nombre,
|
||||
child: Text(l10n.stationOrderByName),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: OrdenEmisoras.calidad,
|
||||
child: Text(l10n.stationOrderByQuality),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
footer: Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: _CtaEmisoraPersonalizada(
|
||||
onTap: _abrirFormularioEmisoraPersonalizada,
|
||||
),
|
||||
),
|
||||
onReorderItem:
|
||||
(oldIndex, newIndex) =>
|
||||
_onReorder(filtrados, favoritos, oldIndex, newIndex),
|
||||
children: [
|
||||
for (var i = 0; i < filtrados.length; i++)
|
||||
_FilaFavorito(
|
||||
key: ValueKey(filtrados[i].uuid),
|
||||
index: i,
|
||||
emisora: filtrados[i],
|
||||
grupos: gruposVisibles,
|
||||
grupoActual: gruposVisibles.firstWhere(
|
||||
(g) => g.id == filtrados[i].grupoFavoritosId,
|
||||
orElse: () => gruposVisibles.first,
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
PluriLayout.horizontal,
|
||||
4,
|
||||
PluriLayout.horizontal,
|
||||
PluriLayout.bottomChromeInset,
|
||||
),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildListDelegate([
|
||||
for (final grupo in gruposVisibles) ...[
|
||||
_GrupoFavoritosPanel(
|
||||
grupo: grupo,
|
||||
grupos: gruposVisibles,
|
||||
emisoras:
|
||||
favoritos
|
||||
.where((e) => e.grupoFavoritosId == grupo.id)
|
||||
.toList(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
]),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GrupoFavoritosPanel extends StatelessWidget {
|
||||
const _GrupoFavoritosPanel({
|
||||
required this.grupo,
|
||||
class _FilaChipsGrupos extends StatelessWidget {
|
||||
const _FilaChipsGrupos({
|
||||
required this.grupos,
|
||||
required this.emisoras,
|
||||
required this.favoritos,
|
||||
required this.seleccionado,
|
||||
required this.onSeleccionar,
|
||||
required this.onGestionar,
|
||||
});
|
||||
|
||||
final GrupoFavoritos grupo;
|
||||
final List<GrupoFavoritos> grupos;
|
||||
final List<Emisora> emisoras;
|
||||
final List<Emisora> favoritos;
|
||||
final String? seleccionado;
|
||||
final ValueChanged<String?> onSeleccionar;
|
||||
final VoidCallback onGestionar;
|
||||
|
||||
String _nombreVisible(AppLocalizations l10n, GrupoFavoritos grupo) =>
|
||||
grupo.esSinAsignar ? l10n.favoriteGroupsUnassigned : grupo.nombre;
|
||||
@@ -125,61 +258,61 @@ class _GrupoFavoritosPanel extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
return PluriGlassSurface(
|
||||
padding: const EdgeInsets.all(10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
return SizedBox(
|
||||
height: 40,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
grupo.esSinAsignar ? Icons.lock_rounded : Icons.folder_rounded,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_nombreVisible(l10n, grupo),
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ChoiceChip(
|
||||
label: Text(
|
||||
l10n.favoriteGroupsChipLabel(
|
||||
l10n.favoritesFilterAllLabel,
|
||||
favoritos.length,
|
||||
),
|
||||
),
|
||||
// S5-R5: proper plural message, not a bare number.
|
||||
Text(l10n.stationCount(emisoras.length)),
|
||||
],
|
||||
selected: seleccionado == null,
|
||||
onSelected: (_) => onSeleccionar(null),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (emisoras.isEmpty)
|
||||
for (final grupo in grupos)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text(
|
||||
l10n.favoritesEmptyTitle,
|
||||
style: theme.textTheme.bodySmall,
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ChoiceChip(
|
||||
label: Text(
|
||||
l10n.favoriteGroupsChipLabel(
|
||||
_nombreVisible(l10n, grupo),
|
||||
favoritos
|
||||
.where((e) => e.grupoFavoritosId == grupo.id)
|
||||
.length,
|
||||
),
|
||||
),
|
||||
selected: seleccionado == grupo.id,
|
||||
onSelected: (_) => onSeleccionar(grupo.id),
|
||||
),
|
||||
)
|
||||
else
|
||||
for (var i = 0; i < emisoras.length; i++) ...[
|
||||
_FavoritoItem(
|
||||
emisora: emisoras[i],
|
||||
grupos: grupos,
|
||||
grupoActual: grupo,
|
||||
),
|
||||
if (i < emisoras.length - 1) const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
ActionChip(
|
||||
avatar: const Icon(Icons.add_rounded, size: 18),
|
||||
label: Text(l10n.favoriteGroupsManage),
|
||||
onPressed: onGestionar,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FavoritoItem extends StatelessWidget {
|
||||
const _FavoritoItem({
|
||||
class _FilaFavorito extends StatelessWidget {
|
||||
const _FilaFavorito({
|
||||
super.key,
|
||||
required this.index,
|
||||
required this.emisora,
|
||||
required this.grupos,
|
||||
required this.grupoActual,
|
||||
});
|
||||
|
||||
final int index;
|
||||
final Emisora emisora;
|
||||
final List<GrupoFavoritos> grupos;
|
||||
final GrupoFavoritos grupoActual;
|
||||
@@ -253,35 +386,123 @@ class _FavoritoItem extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TarjetaEmisora(
|
||||
key: Key(emisora.uuid),
|
||||
emisora: emisora,
|
||||
esCompacta: true,
|
||||
onTap: () => reproducirMinimizado(context, emisora),
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.only(right: 4),
|
||||
child: Icon(Icons.drag_handle_rounded),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton.filledTonal(
|
||||
tooltip: l10n.favoriteGroupsAssignSubtitle(
|
||||
_nombreVisible(l10n, grupoActual),
|
||||
Expanded(
|
||||
child: TarjetaEmisora(
|
||||
key: Key(emisora.uuid),
|
||||
emisora: emisora,
|
||||
esCompacta: true,
|
||||
onTap: () => reproducirMinimizado(context, emisora),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton.filledTonal(
|
||||
tooltip: l10n.favoriteGroupsAssignSubtitle(
|
||||
_nombreVisible(l10n, grupoActual),
|
||||
),
|
||||
icon: const Icon(Icons.drive_file_move_rounded),
|
||||
onPressed: () => _asignar(context),
|
||||
),
|
||||
icon: const Icon(Icons.drive_file_move_rounded),
|
||||
onPressed: () => _asignar(context),
|
||||
),
|
||||
IconButton.filledTonal(
|
||||
tooltip: l10n.favoritesRemoveTooltip,
|
||||
icon: const Icon(Icons.delete_outline_rounded),
|
||||
onPressed: () => _eliminar(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
IconButton.filledTonal(
|
||||
tooltip: l10n.favoritesRemoveTooltip,
|
||||
icon: const Icon(Icons.delete_outline_rounded),
|
||||
onPressed: () => _eliminar(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The dashed "Añadir emisora personalizada" CTA (favorites-organization
|
||||
/// spec, "Custom-Station CTA Preserved") — opens the SAME add-station form
|
||||
/// used from Settings' Emisoras personalizadas screen
|
||||
/// ([FormularioEmisoraPersonalizada]), not a duplicate.
|
||||
class _CtaEmisoraPersonalizada extends StatelessWidget {
|
||||
const _CtaEmisoraPersonalizada({required this.onTap});
|
||||
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final color = Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.5);
|
||||
return CustomPaint(
|
||||
painter: _DashedBorderPainter(color: color),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.add_circle_outline_rounded, color: color),
|
||||
const SizedBox(width: 8),
|
||||
Text(l10n.customStationsAddCta, style: TextStyle(color: color)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DashedBorderPainter extends CustomPainter {
|
||||
const _DashedBorderPainter({required this.color});
|
||||
|
||||
final Color color;
|
||||
|
||||
static const _radius = 16.0;
|
||||
static const _dashWidth = 6.0;
|
||||
static const _gapWidth = 4.0;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final rrect = RRect.fromRectAndRadius(
|
||||
Offset.zero & size,
|
||||
const Radius.circular(_radius),
|
||||
);
|
||||
final path = Path()..addRRect(rrect);
|
||||
final paint =
|
||||
Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.5;
|
||||
for (final metric in path.computeMetrics()) {
|
||||
var distance = 0.0;
|
||||
while (distance < metric.length) {
|
||||
final next = distance + _dashWidth;
|
||||
canvas.drawPath(
|
||||
metric.extractPath(distance, next.clamp(0.0, metric.length)),
|
||||
paint,
|
||||
);
|
||||
distance = next + _gapWidth;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _DashedBorderPainter oldDelegate) =>
|
||||
oldDelegate.color != color;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:share_plus/share_plus.dart' show Share, XFile;
|
||||
|
||||
import '../estado/estado_grabacion.dart';
|
||||
import '../l10n/gen/app_localizations.dart';
|
||||
import '../modelos/archivo_grabacion.dart';
|
||||
import '../widgets/pluri_glass_surface.dart';
|
||||
import '../widgets/pluri_icon.dart';
|
||||
import '../widgets/pluri_layout.dart';
|
||||
import '../widgets/pluri_premium_widgets.dart';
|
||||
import '../widgets/pluri_push_scaffold.dart';
|
||||
import 'ajustes/pantalla_ajustes_grabaciones.dart';
|
||||
|
||||
/// Inline-preview playback + duration lookup for a single recording file at
|
||||
/// a time (WU15, recordings-library spec — "Row playback starts and
|
||||
/// stops"). Kept separate from `ServicioAudio` (never touched — that class
|
||||
/// is coupled to live radio-stream transport/reconnect, unrelated to
|
||||
/// previewing an already-finished local recording).
|
||||
///
|
||||
/// The real implementation ([_ReproductorGrabacionesJustAudio]) wraps
|
||||
/// `just_audio.AudioPlayer`, which needs platform `MethodChannel`s this
|
||||
/// suite does not mock — the same constraint `cola_local_test.dart`
|
||||
/// documents for `PluriWaveAudioHandler` — so it is static-review-only.
|
||||
/// Every test in `pantalla_grabaciones_test.dart` injects a fake instead.
|
||||
abstract class ReproductorGrabaciones {
|
||||
/// Path currently loaded/playing, or null.
|
||||
String? get rutaActual;
|
||||
|
||||
/// True while [rutaActual] is actively playing (not just loaded/paused).
|
||||
bool get reproduciendo;
|
||||
|
||||
/// Loads [ruta]'s metadata and returns its duration, without playing it.
|
||||
Future<Duration?> duracionDe(String ruta);
|
||||
|
||||
/// Starts playback of [ruta]. If [ruta] is already the one playing, this
|
||||
/// pauses it instead — the row's play/pause affordance is a toggle.
|
||||
Future<void> alternar(String ruta);
|
||||
|
||||
Future<void> detener();
|
||||
Future<void> dispose();
|
||||
}
|
||||
|
||||
class _ReproductorGrabacionesJustAudio implements ReproductorGrabaciones {
|
||||
final AudioPlayer _player = AudioPlayer();
|
||||
String? _rutaActual;
|
||||
|
||||
@override
|
||||
String? get rutaActual => _rutaActual;
|
||||
|
||||
@override
|
||||
bool get reproduciendo => _player.playing;
|
||||
|
||||
@override
|
||||
Future<Duration?> duracionDe(String ruta) async {
|
||||
final sonda = AudioPlayer();
|
||||
try {
|
||||
return await sonda.setFilePath(ruta);
|
||||
} finally {
|
||||
await sonda.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> alternar(String ruta) async {
|
||||
if (_rutaActual == ruta && _player.playing) {
|
||||
await _player.pause();
|
||||
return;
|
||||
}
|
||||
if (_rutaActual != ruta) {
|
||||
await _player.setFilePath(ruta);
|
||||
_rutaActual = ruta;
|
||||
}
|
||||
await _player.play();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> detener() async {
|
||||
await _player.stop();
|
||||
_rutaActual = null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() => _player.dispose();
|
||||
}
|
||||
|
||||
/// WU15: the recordings library — storage usage, browsable rows with
|
||||
/// inline playback, and a "⋮" menu constrained to exactly
|
||||
/// Rename/Share/Delete (`recordings-library` spec). Distinct from
|
||||
/// `PantallaAjustesGrabaciones` (WU3b), which is the folder/size-limit
|
||||
/// SETTINGS screen, not this browsable file list.
|
||||
class PantallaGrabaciones extends StatefulWidget {
|
||||
const PantallaGrabaciones({
|
||||
super.key,
|
||||
ReproductorGrabaciones? reproductor,
|
||||
Future<void> Function(String ruta)? compartir,
|
||||
}) : _reproductorInyectado = reproductor,
|
||||
_compartirInyectado = compartir;
|
||||
|
||||
final ReproductorGrabaciones? _reproductorInyectado;
|
||||
final Future<void> Function(String ruta)? _compartirInyectado;
|
||||
|
||||
@override
|
||||
State<PantallaGrabaciones> createState() => _PantallaGrabacionesState();
|
||||
}
|
||||
|
||||
class _PantallaGrabacionesState extends State<PantallaGrabaciones> {
|
||||
late final ReproductorGrabaciones _reproductor =
|
||||
widget._reproductorInyectado ?? _ReproductorGrabacionesJustAudio();
|
||||
late final Future<void> Function(String ruta) _compartir =
|
||||
widget._compartirInyectado ?? (ruta) => Share.shareXFiles([XFile(ruta)]);
|
||||
|
||||
late Future<List<ArchivoGrabacion>> _grabaciones;
|
||||
final Map<String, Future<Duration?>> _duracionCache = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_recargar();
|
||||
}
|
||||
|
||||
void _recargar() {
|
||||
_duracionCache.clear();
|
||||
_grabaciones = context.read<EstadoGrabacion>().listarGrabaciones();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
unawaited(_reproductor.dispose());
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<Duration?> _duracionPara(String ruta) =>
|
||||
_duracionCache.putIfAbsent(ruta, () => _reproductor.duracionDe(ruta));
|
||||
|
||||
Future<void> _alternarReproduccion(String ruta) async {
|
||||
await _reproductor.alternar(ruta);
|
||||
if (!mounted) return;
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _manejarAccion(String accion, ArchivoGrabacion archivo) async {
|
||||
// Yield one microtask before opening any dialog: `PopupMenuButton`'s own
|
||||
// route is still popping off the Navigator at the moment `onSelected`
|
||||
// fires, and pushing a new route (showDialog) synchronously against
|
||||
// that in-flight pop can race its close transition.
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
if (accion == 'rename') {
|
||||
await _renombrar(archivo);
|
||||
return;
|
||||
}
|
||||
if (accion == 'share') {
|
||||
await _compartir(archivo.ruta);
|
||||
return;
|
||||
}
|
||||
if (accion == 'delete') {
|
||||
await _eliminar(archivo);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _renombrar(ArchivoGrabacion archivo) async {
|
||||
final nuevoNombre = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (_) => _DialogoRenombrarGrabacion(nombreActual: archivo.nombre),
|
||||
);
|
||||
if (nuevoNombre == null || nuevoNombre.trim().isEmpty) return;
|
||||
if (!mounted) return;
|
||||
await context.read<EstadoGrabacion>().renombrarGrabacion(
|
||||
archivo.ruta,
|
||||
nuevoNombre.trim(),
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(_recargar);
|
||||
}
|
||||
|
||||
Future<void> _eliminar(ArchivoGrabacion archivo) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final confirmar = await showDialog<bool>(
|
||||
context: context,
|
||||
builder:
|
||||
(ctx) => AlertDialog(
|
||||
title: Text(l10n.recordingDeleteConfirmTitle),
|
||||
content: Text(l10n.recordingDeleteConfirmMessage),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: Text(l10n.cancelAction),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: Text(l10n.recordingActionDelete),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmar != true) return;
|
||||
if (!mounted) return;
|
||||
await context.read<EstadoGrabacion>().eliminarGrabacion(archivo.ruta);
|
||||
if (!mounted) return;
|
||||
setState(_recargar);
|
||||
}
|
||||
|
||||
String _formatearDuracion(Duration? d) {
|
||||
if (d == null) return '--:--';
|
||||
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
|
||||
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||
final h = d.inHours;
|
||||
return h > 0 ? '$h:$m:$s' : '$m:$s';
|
||||
}
|
||||
|
||||
String _formatearFecha(DateTime fecha) {
|
||||
final dia = fecha.day.toString().padLeft(2, '0');
|
||||
final mes = fecha.month.toString().padLeft(2, '0');
|
||||
return '$dia/$mes/${fecha.year}';
|
||||
}
|
||||
|
||||
String _formatearBytes(int bytes) {
|
||||
if (bytes < 1024) return '$bytes B';
|
||||
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
return PluriPushScaffold(
|
||||
title: l10n.recordingsLibraryTitle,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
tooltip: l10n.recordingsLibrarySettingsTooltip,
|
||||
onPressed:
|
||||
() => PluriPushScaffold.push(
|
||||
context,
|
||||
(_) => const PantallaAjustesGrabaciones(),
|
||||
),
|
||||
),
|
||||
],
|
||||
body: FutureBuilder<List<ArchivoGrabacion>>(
|
||||
future: _grabaciones,
|
||||
builder: (context, snap) {
|
||||
final archivos = snap.data ?? const <ArchivoGrabacion>[];
|
||||
return ListView(
|
||||
padding: PluriLayout.pageContentPadding,
|
||||
children: [
|
||||
_BarraDeAlmacenamiento(archivos: archivos),
|
||||
const SizedBox(height: 12),
|
||||
if (snap.connectionState == ConnectionState.done &&
|
||||
archivos.isEmpty)
|
||||
PluriEmptyState(
|
||||
glyph: PluriIconGlyph.player,
|
||||
title: l10n.recordingsLibraryEmptyTitle,
|
||||
subtitle: l10n.recordingsLibraryEmptySubtitle,
|
||||
)
|
||||
else
|
||||
for (final archivo in archivos)
|
||||
_FilaGrabacion(
|
||||
archivo: archivo,
|
||||
reproduciendo:
|
||||
_reproductor.rutaActual == archivo.ruta &&
|
||||
_reproductor.reproduciendo,
|
||||
duracion: _duracionPara(archivo.ruta),
|
||||
formatearDuracion: _formatearDuracion,
|
||||
formatearFecha: _formatearFecha,
|
||||
formatearBytes: _formatearBytes,
|
||||
onAlternarReproduccion:
|
||||
() => _alternarReproduccion(archivo.ruta),
|
||||
onAccionMenu: (accion) => _manejarAccion(accion, archivo),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BarraDeAlmacenamiento extends StatelessWidget {
|
||||
const _BarraDeAlmacenamiento({required this.archivos});
|
||||
|
||||
final List<ArchivoGrabacion> archivos;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final estado = context.watch<EstadoGrabacion>();
|
||||
final usadoBytes = archivos.fold<int>(0, (s, a) => s + a.tamanoBytes);
|
||||
final totalBytes = estado.maxBytes <= 0 ? 1 : estado.maxBytes;
|
||||
final fraccion = (usadoBytes / totalBytes).clamp(0.0, 1.0);
|
||||
final usadoMb = (usadoBytes / (1024 * 1024)).round();
|
||||
final totalMb = (totalBytes / (1024 * 1024)).round();
|
||||
|
||||
return PluriGlassSurface(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: LinearProgressIndicator(value: fraccion, minHeight: 8),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
l10n.recordingsLibraryStorageCaption(usadoMb, totalMb),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FilaGrabacion extends StatelessWidget {
|
||||
const _FilaGrabacion({
|
||||
required this.archivo,
|
||||
required this.reproduciendo,
|
||||
required this.duracion,
|
||||
required this.formatearDuracion,
|
||||
required this.formatearFecha,
|
||||
required this.formatearBytes,
|
||||
required this.onAlternarReproduccion,
|
||||
required this.onAccionMenu,
|
||||
});
|
||||
|
||||
final ArchivoGrabacion archivo;
|
||||
final bool reproduciendo;
|
||||
final Future<Duration?> duracion;
|
||||
final String Function(Duration?) formatearDuracion;
|
||||
final String Function(DateTime) formatearFecha;
|
||||
final String Function(int) formatearBytes;
|
||||
final VoidCallback onAlternarReproduccion;
|
||||
final ValueChanged<String> onAccionMenu;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
return PluriGlassSurface(
|
||||
child: ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: IconButton(
|
||||
icon: Icon(
|
||||
reproduciendo
|
||||
? Icons.pause_circle_filled_rounded
|
||||
: Icons.play_circle_fill_rounded,
|
||||
),
|
||||
onPressed: onAlternarReproduccion,
|
||||
),
|
||||
title: Text(archivo.nombre),
|
||||
subtitle: FutureBuilder<Duration?>(
|
||||
future: duracion,
|
||||
builder: (context, snap) {
|
||||
return Text(
|
||||
'${formatearFecha(archivo.fecha)} · '
|
||||
'${formatearDuracion(snap.data)} · '
|
||||
'${formatearBytes(archivo.tamanoBytes)}',
|
||||
);
|
||||
},
|
||||
),
|
||||
trailing: PopupMenuButton<String>(
|
||||
icon: const Icon(Icons.more_vert_rounded),
|
||||
onSelected: onAccionMenu,
|
||||
itemBuilder:
|
||||
(context) => [
|
||||
PopupMenuItem(
|
||||
value: 'rename',
|
||||
child: Text(l10n.recordingActionRename),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'share',
|
||||
child: Text(l10n.recordingActionShare),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Text(l10n.recordingActionDelete),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns its own [TextEditingController] and disposes it in its own
|
||||
/// [State.dispose] — the SAFE pattern documented for this codebase (see
|
||||
/// `_DialogoEdicionDispositivo` in `pantalla_ajustes_salida_audio.dart`),
|
||||
/// deliberately NOT the pre-existing anti-pattern (dispose right after the
|
||||
/// sheet/dialog Future resolves, racing the close animation) already
|
||||
/// tracked elsewhere in this codebase as a separate, un-fixed defect.
|
||||
class _DialogoRenombrarGrabacion extends StatefulWidget {
|
||||
const _DialogoRenombrarGrabacion({required this.nombreActual});
|
||||
|
||||
final String nombreActual;
|
||||
|
||||
@override
|
||||
State<_DialogoRenombrarGrabacion> createState() =>
|
||||
_DialogoRenombrarGrabacionState();
|
||||
}
|
||||
|
||||
class _DialogoRenombrarGrabacionState
|
||||
extends State<_DialogoRenombrarGrabacion> {
|
||||
late final _controller = TextEditingController(text: widget.nombreActual);
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _confirmar(AppLocalizations l10n) {
|
||||
final valor = _controller.text.trim();
|
||||
if (valor.isEmpty) {
|
||||
setState(() => _error = l10n.recordingRenameEmptyError);
|
||||
return;
|
||||
}
|
||||
Navigator.pop(context, valor);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return AlertDialog(
|
||||
title: Text(l10n.recordingRenameDialogTitle),
|
||||
content: TextField(
|
||||
controller: _controller,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.recordingRenameLabel,
|
||||
errorText: _error,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(l10n.cancelAction),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => _confirmar(l10n),
|
||||
child: Text(l10n.recordingActionRename),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,26 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shimmer/shimmer.dart' as shimmer;
|
||||
|
||||
import '../estado/estado_busqueda.dart';
|
||||
import '../estado/estado_ecualizador.dart';
|
||||
import '../estado/estado_navegacion.dart';
|
||||
import '../estado/estado_radio.dart';
|
||||
import '../l10n/display_names.dart';
|
||||
import '../l10n/gen/app_localizations.dart';
|
||||
import '../modelos/emisora.dart';
|
||||
import '../servicios/servicio_audio.dart';
|
||||
import '../tema/pluri_animate.dart';
|
||||
import '../tema/pluriwave_theme.dart';
|
||||
import '../widgets/pluri_glass_surface.dart';
|
||||
import '../widgets/pluri_icon.dart';
|
||||
import '../widgets/pluri_layout.dart';
|
||||
import '../widgets/pluri_premium_widgets.dart';
|
||||
import '../widgets/visualizador_audio.dart';
|
||||
import 'package:pluriwave/widgets/tarjeta_emisora.dart';
|
||||
|
||||
import 'pantalla_reproductor.dart';
|
||||
import 'reproducir_minimizado.dart';
|
||||
|
||||
/// Pantalla principal: emisoras populares y por género.
|
||||
@@ -53,7 +61,13 @@ class _PantallaInicioState extends State<PantallaInicio> {
|
||||
onRefresh: () => context.read<EstadoRadio>().cargarPopulares(),
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(child: _heroHeader(context, l10n)),
|
||||
// WU5: replaces the old PluriScreenHeader hero. The discovery
|
||||
// sections below (_seccionCercanas onward) are deliberately left
|
||||
// in place — WU6 relocates them to Buscar and deletes them from
|
||||
// here; removing them now would leave that content nowhere until
|
||||
// WU6 lands.
|
||||
const SliverToBoxAdapter(child: _EscucharHero()),
|
||||
SliverToBoxAdapter(child: _seccionTusEmisoras(context, theme, l10n)),
|
||||
SliverToBoxAdapter(child: _seccionCercanas(context, theme, l10n)),
|
||||
SliverToBoxAdapter(child: _seccionTendencias(context, theme, l10n)),
|
||||
SliverToBoxAdapter(child: _chipGeneros(context, theme, l10n)),
|
||||
@@ -62,11 +76,15 @@ class _PantallaInicioState extends State<PantallaInicio> {
|
||||
child: _errorBanner(context, error, theme, l10n),
|
||||
),
|
||||
SliverPadding(
|
||||
// ADR-7(b): the mini player is hidden on this whole screen
|
||||
// (app.dart), so its content needs less bottom padding than
|
||||
// every other root — escucharBottomChromeInset, not the plain
|
||||
// bottomChromeInset every other root/scrollable uses.
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
PluriLayout.horizontal,
|
||||
0,
|
||||
PluriLayout.horizontal,
|
||||
PluriLayout.bottomChromeInset,
|
||||
PluriLayout.escucharBottomChromeInset,
|
||||
),
|
||||
sliver: _gridEmisoras(context, l10n),
|
||||
),
|
||||
@@ -75,27 +93,84 @@ class _PantallaInicioState extends State<PantallaInicio> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _heroHeader(BuildContext context, AppLocalizations l10n) {
|
||||
final totalEmisoras = context.select<EstadoRadio, int>(
|
||||
(e) => e.emisorasInicio.length,
|
||||
/// WU5 task 5.8: a preview of `listaFavoritos` (capped — full browsing,
|
||||
/// filtering, and reordering live on Favoritos itself, WU4), with "Ver
|
||||
/// todas" switching the root tab via `EstadoNavegacionRaiz.irA` rather
|
||||
/// than pushing a route (`app-navigation-shell` — Root-to-Root Switching
|
||||
/// Without Push).
|
||||
static const _capTusEmisoras = 6;
|
||||
|
||||
Widget _seccionTusEmisoras(
|
||||
BuildContext context,
|
||||
ThemeData theme,
|
||||
AppLocalizations l10n,
|
||||
) {
|
||||
final favoritos = context.select<EstadoRadio, List<Emisora>>(
|
||||
(e) => e.listaFavoritos,
|
||||
);
|
||||
return PluriScreenHeader(
|
||||
title: l10n.appTitle,
|
||||
subtitle: l10n.homeScreenSubtitle,
|
||||
glyph: PluriIconGlyph.home,
|
||||
primaryActionLabel: l10n.exploreStations,
|
||||
onPrimaryAction: () => context.read<EstadoRadio>().cargarPopulares(),
|
||||
trailing: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
PluriStatusPill(
|
||||
icon: Icons.public_rounded,
|
||||
label: l10n.stationsCount(totalEmisoras),
|
||||
accent: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
PluriStatusPill(icon: Icons.hd_rounded, label: l10n.qualityHd),
|
||||
],
|
||||
final mostrados = favoritos.take(_capTusEmisoras).toList();
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
PluriLayout.horizontal,
|
||||
8,
|
||||
PluriLayout.horizontal,
|
||||
0,
|
||||
),
|
||||
child: PluriGlassSurface(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.yourStationsTitle,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed:
|
||||
() => context.read<EstadoNavegacionRaiz>().irA(
|
||||
RaizPluriWave.favoritos,
|
||||
),
|
||||
child: Text(l10n.seeAllAction),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (mostrados.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text(
|
||||
l10n.favoritesEmptySubtitle,
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
)
|
||||
else
|
||||
SizedBox(
|
||||
height: 76,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: mostrados.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
||||
itemBuilder: (context, i) {
|
||||
final emisora = mostrados[i];
|
||||
return SizedBox(
|
||||
width: 260,
|
||||
child: TarjetaEmisora(
|
||||
emisora: emisora,
|
||||
esCompacta: true,
|
||||
onTap: () => reproducirMinimizado(context, emisora),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -410,3 +485,378 @@ class _ChipShimmer extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// WU5, design ADR-7: the Escuchar embedded player. `EstadoRadio` is the
|
||||
/// single source of truth (`emisoraActual` already feeds `MiniReproductor`
|
||||
/// and `PantallaReproductor`) — this hero is a third VIEW, never a third
|
||||
/// STATE.
|
||||
///
|
||||
/// Binding rules this class exists to enforce:
|
||||
/// 1. StatelessWidget — no cached `Emisora`, no local playback flag.
|
||||
/// 2. Reads use `context.select` per scalar (here, `emisoraActual` itself —
|
||||
/// `Emisora`'s own `==`/`hashCode` are uuid-based, so this only rebuilds
|
||||
/// the hero when the STATION actually changes, not on every audio buffer
|
||||
/// event `EstadoRadio` also notifies on). Fast-changing playback status
|
||||
/// (`EstadoReproduccion`) is read via `StreamBuilder` instead, the same
|
||||
/// pattern `_Controles`/`MiniReproductor` already use, so status ticks
|
||||
/// don't even reach this widget's own rebuild path.
|
||||
/// 3. Transport calls the SAME `EstadoRadio`/`EstadoEcualizador` methods the
|
||||
/// full player calls — no new playback methods.
|
||||
/// 4. `VisualizadorAudio` is reused UNCHANGED, just re-parameterised
|
||||
/// (`barras: 30`, `altura: 26`, `color: liveGreen`).
|
||||
class _EscucharHero extends StatelessWidget {
|
||||
const _EscucharHero();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final emisora = context.select<EstadoRadio, Emisora?>(
|
||||
(e) => e.emisoraActual,
|
||||
);
|
||||
|
||||
if (emisora == null) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
PluriLayout.horizontal,
|
||||
8,
|
||||
PluriLayout.horizontal,
|
||||
0,
|
||||
),
|
||||
child: PluriGlassSurface(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.nothingPlayingTitle,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
l10n.nothingPlayingSubtitle,
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final estado = context.read<EstadoRadio>();
|
||||
final stationName = localizedStationName(l10n, emisora.nombre);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
PluriLayout.horizontal,
|
||||
8,
|
||||
PluriLayout.horizontal,
|
||||
0,
|
||||
),
|
||||
child: PluriGlassSurface(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_ArteEscuchar(emisora: emisora),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
StreamBuilder<EstadoReproduccion>(
|
||||
stream: estado.estadoStream,
|
||||
builder: (context, snap) {
|
||||
final s = snap.data ?? EstadoReproduccion.detenido;
|
||||
final enVivo = s == EstadoReproduccion.reproduciendo;
|
||||
return PluriStatusPill(
|
||||
icon:
|
||||
enVivo
|
||||
? Icons.podcasts_rounded
|
||||
: Icons.pause_circle_outline_rounded,
|
||||
label: enVivo ? l10n.liveNow : l10n.notPlaying,
|
||||
accent:
|
||||
enVivo ? context.pluriTokens.liveGreen : null,
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
stationName,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
VisualizadorAudio(
|
||||
estadoStream: estado.estadoStream,
|
||||
androidAudioSessionIdStream:
|
||||
estado.audio.androidAudioSessionIdStream,
|
||||
barras: 30,
|
||||
altura: 26,
|
||||
color: context.pluriTokens.liveGreen,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_FilaTransporteEscuchar(emisora: emisora),
|
||||
const SizedBox(height: 10),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: OutlinedButton.icon(
|
||||
icon: const Icon(Icons.tune_rounded, size: 18),
|
||||
label: Text(l10n.openFullPlayerTooltip),
|
||||
onPressed: () => PantallaReproductor.abrir(context, emisora),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Square art (the design's requested shape for the Escuchar hero — the
|
||||
/// full player's own `_WaveHero`, `pantalla_reproductor.dart`, stays
|
||||
/// circular and unchanged).
|
||||
class _ArteEscuchar extends StatelessWidget {
|
||||
const _ArteEscuchar({required this.emisora});
|
||||
|
||||
final Emisora emisora;
|
||||
|
||||
static const _lado = 84.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final radius = BorderRadius.circular(context.pluriTokens.radiusMd);
|
||||
return PluriGlassSurface(
|
||||
padding: EdgeInsets.zero,
|
||||
borderRadius: radius,
|
||||
child: SizedBox(
|
||||
width: _lado,
|
||||
height: _lado,
|
||||
child: ClipRRect(
|
||||
borderRadius: radius,
|
||||
child:
|
||||
(emisora.favicon != null && emisora.favicon!.isNotEmpty)
|
||||
? CachedNetworkImage(
|
||||
imageUrl: emisora.favicon!,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => _shimmerCuadrado(theme),
|
||||
errorWidget: (_, __, ___) => _iconoFallback(theme),
|
||||
)
|
||||
: _iconoFallback(theme),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _shimmerCuadrado(ThemeData theme) => shimmer.Shimmer.fromColors(
|
||||
baseColor: theme.colorScheme.surfaceContainerHighest,
|
||||
highlightColor: theme.colorScheme.surface,
|
||||
child: Container(color: theme.colorScheme.surfaceContainerHighest),
|
||||
);
|
||||
|
||||
Widget _iconoFallback(ThemeData theme) => Container(
|
||||
color: theme.colorScheme.primaryContainer,
|
||||
child: Icon(
|
||||
Icons.radio_rounded,
|
||||
size: 36,
|
||||
color: theme.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The hero's transport row — favorite / EQ toggle / stop / play-pause
|
||||
/// (primary) / sleep, in that order, with sleep as the documented 5th
|
||||
/// action. Every action calls an EXISTING `EstadoRadio`/`EstadoEcualizador`
|
||||
/// method — no new playback surface (ADR-7 rule 3).
|
||||
class _FilaTransporteEscuchar extends StatelessWidget {
|
||||
const _FilaTransporteEscuchar({required this.emisora});
|
||||
|
||||
final Emisora emisora;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final t = context.pluriTokens;
|
||||
final estado = context.read<EstadoRadio>();
|
||||
final esFavorito = context.select<EstadoRadio, bool>(
|
||||
(e) => e.listaFavoritos.any((x) => x.uuid == emisora.uuid),
|
||||
);
|
||||
final eqActivo = context.select<EstadoEcualizador, bool>((e) => e.activo);
|
||||
final timerActivo = context.select<EstadoRadio, bool>(
|
||||
(e) => e.timer.activo,
|
||||
);
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip:
|
||||
esFavorito
|
||||
? l10n.favoritesRemoveTooltip
|
||||
: l10n.favoritesAddTooltip,
|
||||
icon: Icon(
|
||||
esFavorito
|
||||
? Icons.favorite_rounded
|
||||
: Icons.favorite_outline_rounded,
|
||||
color: esFavorito ? Theme.of(context).colorScheme.error : null,
|
||||
),
|
||||
onPressed: () => estado.toggleFavorito(emisora),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: eqActivo ? l10n.equalizerDisable : l10n.equalizerEnable,
|
||||
icon: Icon(
|
||||
eqActivo ? Icons.equalizer_rounded : Icons.equalizer_outlined,
|
||||
color: eqActivo ? t.warmCoral : null,
|
||||
),
|
||||
onPressed:
|
||||
() => context.read<EstadoEcualizador>().cambiarActivo(!eqActivo),
|
||||
),
|
||||
StreamBuilder<EstadoReproduccion>(
|
||||
stream: estado.estadoStream,
|
||||
builder: (context, snap) {
|
||||
final s = snap.data ?? EstadoReproduccion.detenido;
|
||||
final cargando =
|
||||
s == EstadoReproduccion.cargando ||
|
||||
s == EstadoReproduccion.reconectando;
|
||||
return IconButton(
|
||||
tooltip: l10n.stopAction,
|
||||
icon: const Icon(Icons.stop_circle_outlined),
|
||||
onPressed: cargando ? null : estado.detenerReproduccion,
|
||||
);
|
||||
},
|
||||
),
|
||||
StreamBuilder<EstadoReproduccion>(
|
||||
stream: estado.estadoStream,
|
||||
builder: (context, snap) {
|
||||
final s = snap.data ?? EstadoReproduccion.detenido;
|
||||
final reproduciendo = s == EstadoReproduccion.reproduciendo;
|
||||
final cargando =
|
||||
s == EstadoReproduccion.cargando ||
|
||||
s == EstadoReproduccion.reconectando;
|
||||
return SizedBox(
|
||||
width: 56,
|
||||
height: 56,
|
||||
child: FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
shape: const CircleBorder(),
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
onPressed:
|
||||
cargando
|
||||
? null
|
||||
: () {
|
||||
if (reproduciendo ||
|
||||
s == EstadoReproduccion.pausado) {
|
||||
estado.togglePlay();
|
||||
} else {
|
||||
estado.reproducir(emisora);
|
||||
}
|
||||
},
|
||||
child:
|
||||
cargando
|
||||
? const SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Icon(
|
||||
reproduciendo
|
||||
? Icons.pause_rounded
|
||||
: Icons.play_arrow_rounded,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
tooltip: l10n.sleepTimer,
|
||||
icon: Icon(
|
||||
Icons.bedtime_rounded,
|
||||
color: timerActivo ? t.warmCoral : null,
|
||||
),
|
||||
onPressed: () => _mostrarTimerSheet(context, estado, l10n),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _mostrarTimerSheet(
|
||||
BuildContext context,
|
||||
EstadoRadio estado,
|
||||
AppLocalizations l10n,
|
||||
) {
|
||||
return showModalBottomSheet(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder:
|
||||
(ctx) => SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.sleepTimer,
|
||||
style: Theme.of(ctx).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (estado.timer.activo)
|
||||
FilledButton.tonal(
|
||||
onPressed: () {
|
||||
estado.cancelarTimer();
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
child: Text(l10n.cancelTimer),
|
||||
)
|
||||
else
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final segundos in estado.timerSuenoPresetsSegundos)
|
||||
ActionChip(
|
||||
label: Text(_formatearMinutos(l10n, segundos)),
|
||||
onPressed: () {
|
||||
estado.iniciarTimerDuracion(
|
||||
Duration(seconds: segundos),
|
||||
);
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatearMinutos(AppLocalizations l10n, int segundos) {
|
||||
final d = Duration(seconds: segundos);
|
||||
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
|
||||
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||
if (d.inHours > 0) {
|
||||
return l10n.durationHoursMinutesSeconds(d.inHours, m, s);
|
||||
}
|
||||
return d.inSeconds.remainder(60) == 0
|
||||
? l10n.durationMinutesOnly(d.inMinutes)
|
||||
: l10n.durationMinutesSeconds(d.inMinutes, s);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@ import 'dart:io';
|
||||
import 'dart:ui' show Locale;
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../l10n/gen/app_localizations.dart';
|
||||
import '../modelos/archivo_grabacion.dart';
|
||||
import '../modelos/emisora.dart';
|
||||
|
||||
enum EstadoGrabacionRadioTipo {
|
||||
@@ -173,6 +175,66 @@ class ServicioGrabacionRadio {
|
||||
_emitir(_estado);
|
||||
}
|
||||
|
||||
/// Lists the recording files currently on disk in the effective
|
||||
/// directory (WU15, recordings-library spec "Browsable Recordings List").
|
||||
/// Pure filesystem read — no audio decoding, no duration. Never throws: a
|
||||
/// missing directory (never recorded yet) degrades to an empty list, not
|
||||
/// an error, matching the spec's "No recordings (edge case)" scenario.
|
||||
/// Sorted most-recent-first by last-modified time.
|
||||
///
|
||||
/// Uses the SYNC `listSync`/`statSync` filesystem calls deliberately, not
|
||||
/// `Directory.list()`'s async stream — the latter did not resolve inside
|
||||
/// `flutter_test`'s fake-async zone in this sandbox (confirmed: a widget
|
||||
/// test calling through to the stream-based version hung indefinitely,
|
||||
/// while the sync calls resolve immediately), mirroring this project's
|
||||
/// existing `Directory.systemTemp` async-hang precedent for a different
|
||||
/// dart:io API shape.
|
||||
Future<List<ArchivoGrabacion>> listarGrabaciones() async {
|
||||
final ruta = await directorioEfectivo();
|
||||
final directorio = Directory(ruta);
|
||||
if (!directorio.existsSync()) return const [];
|
||||
|
||||
final archivos = <ArchivoGrabacion>[];
|
||||
for (final entidad in directorio.listSync()) {
|
||||
if (entidad is! File) continue;
|
||||
final stat = entidad.statSync();
|
||||
archivos.add(
|
||||
ArchivoGrabacion(
|
||||
ruta: entidad.path,
|
||||
nombre: p.basenameWithoutExtension(entidad.path),
|
||||
fecha: stat.modified,
|
||||
tamanoBytes: stat.size,
|
||||
),
|
||||
);
|
||||
}
|
||||
archivos.sort((a, b) => b.fecha.compareTo(a.fecha));
|
||||
return archivos;
|
||||
}
|
||||
|
||||
/// Deletes the recording at [ruta] (WU15, "Delete removes the file and
|
||||
/// its row"). File-lifecycle management for a file this service already
|
||||
/// created — not a new capability. A missing file is treated as
|
||||
/// already-deleted, never an error.
|
||||
Future<void> eliminarGrabacion(String ruta) async {
|
||||
final archivo = File(ruta);
|
||||
if (await archivo.exists()) {
|
||||
await archivo.delete();
|
||||
}
|
||||
}
|
||||
|
||||
/// Renames the recording at [ruta] to [nuevoNombre], preserving its
|
||||
/// original extension (WU15, "Rename updates the displayed name").
|
||||
/// Returns the new full path.
|
||||
Future<String> renombrarGrabacion(String ruta, String nuevoNombre) async {
|
||||
final original = File(ruta);
|
||||
final nuevaRuta = p.join(
|
||||
p.dirname(ruta),
|
||||
'$nuevoNombre${p.extension(ruta)}',
|
||||
);
|
||||
final renombrado = await original.rename(nuevaRuta);
|
||||
return renombrado.path;
|
||||
}
|
||||
|
||||
Future<void> guardarMaxBytes(int bytes) async {
|
||||
if (bytes <= 0) {
|
||||
throw ArgumentError(_textos.recordingMaxSizeInvalidError);
|
||||
|
||||
@@ -14,7 +14,21 @@ import 'visualizador_audio.dart';
|
||||
/// Barra inferior persistente con controles básicos de reproducción.
|
||||
/// Toca la barra para abrir PantallaReproductor completa.
|
||||
class MiniReproductor extends StatefulWidget {
|
||||
const MiniReproductor({super.key});
|
||||
const MiniReproductor({super.key, this.visible = true});
|
||||
|
||||
/// Design ADR-7(b): on Escuchar, the embedded hero already shows the same
|
||||
/// station, so `_PaginaPrincipal` passes `visible: false` there to avoid
|
||||
/// showing it twice. Hidden VISUALLY only (`build` returns
|
||||
/// `SizedBox.shrink()`) — the widget stays in the tree and mounted, so
|
||||
/// `didChangeDependencies`'s `configurarLocalizaciones` call (S3-R3) keeps
|
||||
/// running on every locale change regardless of which tab is active.
|
||||
final bool visible;
|
||||
|
||||
/// Measured (not guessed) from this widget's actual laid-out height with a
|
||||
/// representative station name, default text scale and theme — see
|
||||
/// `mini_reproductor_configurar_test.dart`'s measurement assertion. Backs
|
||||
/// `PluriLayout.escucharBottomChromeInset` (ADR-7(b)).
|
||||
static const double altura = 72;
|
||||
|
||||
@override
|
||||
State<MiniReproductor> createState() => _MiniReproductorState();
|
||||
@@ -43,7 +57,7 @@ class _MiniReproductorState extends State<MiniReproductor> {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final emisora = estado.emisoraActual;
|
||||
|
||||
if (emisora == null) return const SizedBox.shrink();
|
||||
if (!widget.visible || emisora == null) return const SizedBox.shrink();
|
||||
|
||||
final t = context.pluriTokens;
|
||||
final stationName = localizedStationName(l10n, emisora.nombre);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../tema/pluriwave_theme.dart';
|
||||
import 'mini_reproductor.dart';
|
||||
|
||||
abstract final class PluriLayout {
|
||||
static const double horizontal = 16;
|
||||
@@ -9,6 +10,12 @@ abstract final class PluriLayout {
|
||||
static const double compactGap = 8;
|
||||
static const double bottomChromeInset = 146;
|
||||
|
||||
/// ADR-7(b): `bottomChromeInset` assumes `MiniReproductor` is visible.
|
||||
/// Escuchar hides it (design's one exception — the embedded hero already
|
||||
/// shows the same station), so its content needs less bottom padding.
|
||||
static const double escucharBottomChromeInset =
|
||||
bottomChromeInset - MiniReproductor.altura;
|
||||
|
||||
static const EdgeInsets pageListPadding = EdgeInsets.fromLTRB(
|
||||
0,
|
||||
0,
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
> needs. Tables and checklists are used throughout to keep it scannable despite the length.
|
||||
> **WU12 does not exist** — the native EQ band-count spike was resolved before planning closed (Engram id 2498, "keep
|
||||
> 5 bands"). Numbering skips 11→13 on purpose; this is not a gap.
|
||||
> **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.
|
||||
> Strict TDD is ON. Runner: `flutter test`. `flutter analyze` and a **scoped** `dart format` gate every commit.
|
||||
> **`flutter build` is never run.**
|
||||
>
|
||||
@@ -39,7 +42,7 @@
|
||||
| 1 | `feat(tokens): add design tokens, type scale, push scaffold, and root nav state` | — | 220-330 | Low | No |
|
||||
| 2 | `test(auto): confirm Android Auto tree matches the redesign, no code changes` | — | 0 | None | No |
|
||||
| 3a | `refactor(ajustes): split Settings AUDIO/EMISORAS into pushed detail screens` | 1 | ~~800-1000~~ → **REALIZED: 4,374** (2,760+ / 1,614-, 35 files) | High | **Yes — move-only diff** |
|
||||
| 3b | `refactor(ajustes): split remaining Settings sections into pushed screens` | 3a | **~2,500-3,200**\* | Medium-High | **Yes — move-only diff** |
|
||||
| 3b | `refactor(ajustes): split remaining Settings sections into pushed screens` | 3a | ~~2,500-3,200~~\* → **REALIZED: 2,190** (1,485+ / 705-, 28 files) | Medium-High | **Yes — move-only diff** |
|
||||
| 4 | `feat(favoritos): replace stacked group panels with chip-filtered reorderable list` | 1 | 300-400 | Medium | Monitor |
|
||||
| 5 | `feat(escuchar): replace discovery browser with embedded player and favorites grid` | 1, 4 | 350-450 | Medium | Monitor |
|
||||
| 6 | `feat(buscar): add discovery landing state, filter pills, counter, and sort` | 5 | 350-500 | Medium-High | Monitor |
|
||||
@@ -50,7 +53,8 @@
|
||||
| 11 | `feat(alarma-sonando): restyle ringing screen, drop live countdown label` | 1 | 200-300 | Low (safety-critical review attention: High) | No |
|
||||
| 13 | `feat(eq): restyle equalizer screen and add custom presets` | 3a | 400-550 | Medium-High | Monitor |
|
||||
| 14 | `feat(reproductor): restructure full player with tool-tray and EQ sheet` | 13 | 450-600 | Medium-High | Monitor |
|
||||
| 15 | `feat(grabaciones): add recordings library screen` | 3b | 300-400 | Medium | Monitor |
|
||||
| 15 | `feat(grabaciones): add recordings library screen` | 3b | ~~300-400~~ → **REALIZED: 1,767** (1,767+ / 0-, 22 files) | Medium | Monitor§ |
|
||||
| 15b | `fix(grabaciones): wire the recordings library into Settings navigation` | 15 | 60-100 | Low | No |
|
||||
| 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 |
|
||||
| 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 |
|
||||
@@ -66,13 +70,21 @@ WU3b moves 5 sections rather than 7 and needs no new shared widget (`fila_ajuste
|
||||
~2,500-3,200. **Estimating lesson for every remaining work unit: a strict-TDD commit carries its test files, and any
|
||||
ARB touch drags 13 generated files with it. Estimates that count only `lib/` production code will read ~3-4x low.**
|
||||
|
||||
**Root line count, for reference**: `pantalla_ajustes.dart` went 1,897 → **788** in WU3a. The "under 400 lines"
|
||||
success criterion is the WU3a+WU3b **combined** end state, not WU3a alone — WU3a owns only 7 of the 12 detail
|
||||
screens. WU3b must take the remaining ~388 lines out.
|
||||
**Root line count, for reference**: `pantalla_ajustes.dart` went 1,897 → 788 in WU3a → **198** in WU3b. The "under
|
||||
400 lines" success criterion was the WU3a+WU3b **combined** end state — WU3a owned only 7 of the 12 detail screens.
|
||||
WU3b converted the remaining 5 (Grabaciones, Música local, Idioma, Backup, Info) and the root is now exactly 4
|
||||
`GrupoAjustes` cards, well under the 400-line target.
|
||||
† Record `size:exception` at apply time only if the realized diff exceeds ~500 lines; justification: "largest single
|
||||
alarm-card + hero + vacation-summary restyle, not divisible without breaking the one-commit-per-work-unit rule."
|
||||
‡ The proposal already isolates WU10 as "its own PR, never bundled" — splitting further would leave an unintegrated
|
||||
commit (a widget with no consumer, or a sheet rewrite with no new editor).
|
||||
§ **Re-derived after WU15 landed.** Same estimating lesson as WU3a/WU3b, at brand-new-screen scale: the 300-400
|
||||
figure covered only the production screen, not (a) its matching ~470-line strict-TDD test file, (b) the 13
|
||||
regenerated `lib/l10n/gen/*.dart` files (12 new ARB keys this time, since a genuinely new screen needs new copy,
|
||||
unlike WU3a/WU3b's move-only reuse), or (c) the new `servicio_grabacion_radio.dart` additions and their own test
|
||||
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).
|
||||
|
||||
```text
|
||||
Decision needed before apply: No
|
||||
@@ -226,23 +238,45 @@ modified. Do not attempt to slice under 450.
|
||||
Backup, Info)
|
||||
**Modified tests**: `test/pantallas/pantalla_ajustes_test.dart` (extended)
|
||||
|
||||
- [ ] 3b.1 RED — extend detail-screen tests for the remaining 5 sections, asserting `PluriPushScaffold` usage and
|
||||
preserved controls.
|
||||
- [ ] 3b.2 GREEN — cut `_SeccionGrabaciones` (92-291), `_SeccionMusicaLocal` (291-399) into
|
||||
`pantalla_ajustes_grabaciones.dart`, `pantalla_ajustes_musica_local.dart`.
|
||||
- [ ] 3b.3 GREEN — cut `_SeccionIdioma` + `_IdiomaDisponible` (492-694), `_SeccionBackup` (1669-1791),
|
||||
`_SeccionInfo` (1791-1896) into `pantalla_ajustes_idioma.dart`, `pantalla_ajustes_backup.dart`,
|
||||
`pantalla_ajustes_info.dart`.
|
||||
- [ ] 3b.4 GREEN — in the root, replace the remaining sections with `FilaAjuste` rows under two more `GrupoAjustes`
|
||||
cards (GRABACIONES Y MÚSICA, APLICACIÓN); confirm sleep timer and backup rows are present — corollary ruling,
|
||||
nothing dropped.
|
||||
- [ ] 3b.5 REFACTOR — confirm `pantalla_ajustes.dart` now contains only 4 `GrupoAjustes` cards and is under 400
|
||||
lines; delete now-unused private section widgets.
|
||||
- [ ] 3b.6 Verify — full `pantalla_ajustes_test.dart` suite green; all 12 detail screens present and reachable; no
|
||||
service/state file touched.
|
||||
- [x] 3b.1 RED — extend detail-screen tests for the remaining 5 sections, asserting `PluriPushScaffold` usage and
|
||||
preserved controls. **Applied against the CURRENT 788-line file (re-read fresh at apply time, not the stale
|
||||
92-1896 line references below, which were computed against the pre-WU3a 1,897-line file).** Root test file:
|
||||
removed the now-false "Sections pending WU3b remain inline" and "Phase 7" groups, added a `WU3b — GRABACIONES Y
|
||||
MÚSICA and APLICACIÓN groups` group (5 scenarios: 2-row/3-row zero-inline-control checks, 2 push-navigation
|
||||
checks, one "root is exactly 4 GrupoAjustes cards" check). The 2 relocated Phase-7 friendly-folder-name
|
||||
scenarios moved verbatim into the new `ajustes/pantalla_ajustes_musica_local_test.dart`, targeting the isolated
|
||||
pushed screen directly instead of scrolling to find it inside the whole root.
|
||||
- [x] 3b.2 GREEN — cut `_SeccionGrabaciones`, `_SeccionMusicaLocal` (Stateful) into `pantalla_ajustes_grabaciones.dart`,
|
||||
`pantalla_ajustes_musica_local.dart` — verbatim, header row removed per the established rule.
|
||||
- [x] 3b.3 GREEN — cut `_SeccionIdioma` + `_IdiomaDisponible`, `_SeccionBackup`, `_SeccionInfo` into
|
||||
`pantalla_ajustes_idioma.dart`, `pantalla_ajustes_backup.dart`, `pantalla_ajustes_info.dart`. **Correction
|
||||
found at apply time**: unlike the other 4 sections, `_SeccionInfo` never had its own header icon+title row —
|
||||
its first tile (app name + version) already served that role — so there was no header to strip for that one
|
||||
screen; its body moved in full, unchanged. This is also why `infoSectionTitle` is a genuinely new ARB key (no
|
||||
existing in-body header string covered a bare "Info" label), unlike the other 4 screens which all reuse
|
||||
pre-existing strings.
|
||||
- [x] 3b.4 GREEN — in the root, replaced the remaining 5 sections with `FilaAjuste` rows under two more
|
||||
`GrupoAjustes` cards: RECORDINGS & MUSIC (Recordings, Local music) and APPLICATION (Language, Backup, Info).
|
||||
Confirmed sleep timer (WU3a) and backup/restore (this WU) rows are both present — corollary ruling, nothing
|
||||
dropped. **3 new ARB keys** (en/es only, matching WU1/WU3a's precedent of leaving the other 11 locales for
|
||||
WU18): `settingsGroupRecordingsTitle` ("RECORDINGS & MUSIC"/"GRABACIONES Y MÚSICA"),
|
||||
`settingsGroupApplicationTitle` ("APPLICATION"/"APLICACIÓN") for the two new group eyebrow labels, and
|
||||
`infoSectionTitle` ("Info"/"Información") for the one row with no pre-existing header string (see 3b.3). All
|
||||
other row titles reuse existing ARB keys.
|
||||
- [x] 3b.5 REFACTOR — confirmed `pantalla_ajustes.dart` now contains exactly 4 `GrupoAjustes` cards and is **198
|
||||
lines** (well under the 400-line target); deleted all 5 now-unused private section widgets plus every import
|
||||
that was only needed by their bodies (`dart:async`, `dart:io`, `file_picker`, `package_info_plus`,
|
||||
`path_provider`, `provider`, `share_plus`, `estado_grabacion.dart`, `estado_idioma.dart`, `estado_radio.dart`,
|
||||
`musica_local_auto.dart`, `pluri_glass_surface.dart`, `pluri_onboarding_dialog.dart` — none of these are read
|
||||
by the root anymore since it is pure navigation chrome).
|
||||
- [x] 3b.6 Verify — scoped suite (`pantalla_ajustes_test.dart` + `ajustes/`) green: 45/45. All 12 detail screens
|
||||
present and reachable. `git diff` touches only screen/test/ARB(+generated l10n) files — no service/state file
|
||||
touched. Full suite: 592/592 (up from 579). `flutter analyze`: 1 issue, identical to baseline.
|
||||
|
||||
**`size:exception` — "move-only diff".** ~500-700 changed lines (derived; see forecast table footnote). Same
|
||||
justification as WU3a.
|
||||
**`size:exception` — "move-only diff".** Realized: **2,190 changed lines** (1,485+ / 705-) across 28 files — under
|
||||
the ~2,500-3,200 estimate this time (Música local's compact Stateful body and this batch's conservative choice to
|
||||
verify reachability rather than tap through unmocked native-channel controls for Backup/Grabaciones' riskiest rows
|
||||
both kept it below forecast). Same "move-only diff" justification as WU3a.
|
||||
|
||||
## WU4 — Favoritos restyle
|
||||
|
||||
@@ -250,56 +284,146 @@ justification as WU3a.
|
||||
**Depends on**: WU1
|
||||
**Spec refs**: `favorites-organization` — Chip-Filtered Flat List, Drag-to-Reorder Within the Active Filter, Sort
|
||||
Action Using Existing Criteria, Group Management Reachable from Favoritos, Custom-Station CTA Preserved
|
||||
**Verify**: `flutter test test/pantallas/pantalla_favoritos_plural_test.dart && flutter analyze && dart format --set-exit-if-changed $(git diff --name-only --diff-filter=ACM HEAD -- '*.dart')`
|
||||
**Modified tests**: `test/pantallas/pantalla_favoritos_plural_test.dart` (+ new reorder scenario)
|
||||
**Verify**: `flutter test test/pantallas/pantalla_favoritos_test.dart test/pantallas/pantalla_favoritos_plural_test.dart test/pantallas/ajustes/pantalla_ajustes_emisoras_personalizadas_test.dart && flutter analyze && dart format --set-exit-if-changed $(git diff --cached --name-only --diff-filter=ACM HEAD -- '*.dart')`
|
||||
**New tests**: `test/pantallas/pantalla_favoritos_test.dart` (see 4.1's note — this WU's actual new test file, not
|
||||
`pantalla_favoritos_plural_test.dart`).
|
||||
**Modified tests**: `test/pantallas/ajustes/pantalla_ajustes_emisoras_personalizadas_test.dart` (comment only, no
|
||||
scenario change — see 4.4's note).
|
||||
|
||||
- [ ] 4.1 RED — update the test file: chip filter narrows the list ("Todas · N" + one chip per group); drag-to-reorder
|
||||
persists across a simulated restart; `swap_vert` applies `OrdenEmisoras`; create-group action reachable;
|
||||
custom-station CTA reachable.
|
||||
- [ ] 4.2 GREEN — replace `_GrupoFavoritosPanel` / `_FavoritoItem` with a horizontally-scrollable chip row + a single
|
||||
`ReorderableListView` filtered by the active chip.
|
||||
- [ ] 4.3 GREEN — wire persistence for the new order and the `swap_vert` action to `OrdenEmisoras`.
|
||||
- [ ] 4.4 GREEN — surface the create-group action; keep the dashed custom-station CTA.
|
||||
- [ ] 4.5 REFACTOR — extract the chip row / reorderable row into private widgets if the file grows unwieldy; confirm
|
||||
push-chrome is **not** applied here (Favoritos keeps its tab bar — the one exemption).
|
||||
- [ ] 4.6 Verify — reorder-persists scenario green after simulated restart; sort scenario green.
|
||||
- [x] 4.1 RED — **corrected at apply time**: `pantalla_favoritos_plural_test.dart` (the file this task originally
|
||||
named) never imports `PantallaFavoritos` — it only exercises `stationCount`'s ARB plural formatting via
|
||||
`AppLocalizations` directly, with no widget-level coverage of this screen at all. Left it untouched (still a
|
||||
valid, unrelated regression guard) and created **`test/pantallas/pantalla_favoritos_test.dart`** instead, with:
|
||||
3 state-layer tests for the new `EstadoRadio` surface (`listaFavoritosManual`, `reordenarFavorito`,
|
||||
`ordenarFavoritos` — see 4.3's note) plus 6 widget-level scenarios (empty-state CTA, chip filter narrows the
|
||||
list, drag-reorder persists across a simulated restart, `swap_vert` sort, create-group action + new-group-chip
|
||||
reactivity, custom-station CTA).
|
||||
- [x] 4.2 GREEN — replaced `_GrupoFavoritosPanel` with `_FilaChipsGrupos` (horizontally-scrollable
|
||||
`ChoiceChip` row, "{name} · {count}" label, new ARB keys `favoriteGroupsChipLabel`/`favoritesFilterAllLabel`)
|
||||
and a `ReorderableListView` (`header`/`footer` hold the screen header, chip row, sort action and the CTA;
|
||||
`buildDefaultDragHandles: false` + `ReorderableDragStartListener` per row, matching the spec's "via a drag
|
||||
handle" wording rather than whole-row long-press). Used the modern `onReorderItem` callback, not the
|
||||
now-`@Deprecated` `onReorder` (Flutter 3.44 marks it obsolete — using it would have added a new
|
||||
`deprecated_member_use` warning above the 1-issue baseline).
|
||||
- [x] 4.3 GREEN — **design decision, not specified by any ADR (WU4 has none)**: `EstadoRadio.listaFavoritos` already
|
||||
unconditionally re-sorts by the GLOBAL `ordenListas` setting on every read, which would silently discard any
|
||||
drag-to-reorder the instant anything reloads favorites. Added `listaFavoritosManual` (a new memoized getter
|
||||
returning the stored `orden`-column sequence, untouched by `ordenListas`) instead of changing `listaFavoritos`
|
||||
itself — `navegacion_auto.dart`'s Android Auto tree and the future Escuchar grid (WU5) both read
|
||||
`listaFavoritos` and are correctly unaffected by Favoritos' own manual order. Added `reordenarFavorito(uuid,
|
||||
nuevoIndice)` (thin wrapper over the ALREADY-EXISTING `ServicioFavoritos.reordenar` + `FakeServicioFavoritos`
|
||||
test double — both pre-dated this WU, unused until now) and `ordenarFavoritos(criterio)` (applies
|
||||
`OrdenEmisoras` via the existing `ordenarEmisoras()` function, then PERSISTS the result as the new manual order
|
||||
via the same `reordenar` primitive in a loop — chosen so the sort action's result also survives a restart,
|
||||
consistent with the drag-reorder persistence contract, even though the spec's own sort scenario only asserts
|
||||
the immediate re-render).
|
||||
- [x] 4.4 GREEN — create-group action: an `ActionChip` ("Manage lists", new ARB key `favoriteGroupsManage`) at the
|
||||
end of the chip row pushes the EXISTING `PantallaAjustesGruposFavoritos` (Settings' screen, reused rather than
|
||||
duplicated) — satisfies "Group Management Reachable... in addition to its existing entry point in Settings" as
|
||||
a second entry point to the SAME screen. Custom-station CTA: a new dashed-bordered card (`_DashedBorderPainter`,
|
||||
a small self-contained `CustomPainter` — no dashed-border package was available or added) opens the add-station
|
||||
form directly. That form (`_FormularioEmisora`) was **renamed to public `FormularioEmisoraPersonalizada`** in
|
||||
`pantalla_ajustes_emisoras_personalizadas.dart` so both screens share the one form instead of duplicating it;
|
||||
its one existing test file only referenced the old name in a comment, updated, no scenario changed. New ARB key
|
||||
`customStationsAddCta` ("Add custom station" / "Añadir emisora personalizada", matching the spec's quoted
|
||||
Spanish text exactly).
|
||||
- [x] 4.5 REFACTOR — extracted `_FilaChipsGrupos`, `_FilaFavorito` (the reorderable row, now carrying a leading drag
|
||||
handle plus the pre-existing assign/remove actions), `_CtaEmisoraPersonalizada`, `_DashedBorderPainter` as
|
||||
private widgets. Confirmed `PantallaFavoritos` still constructs zero `Scaffold` (`pluri_push_scaffold_test.dart`
|
||||
"The 5 root screens build zero Scaffold when mounted bare" still passes for it) — push-chrome is not applied
|
||||
here, Favoritos keeps its tab bar.
|
||||
- [x] 4.6 Verify — scoped suite green: 9/9 new + 2/2 `pantalla_favoritos_plural_test.dart` + 2/2
|
||||
`pantalla_ajustes_emisoras_personalizadas_test.dart`. Reorder-persists scenario green (verified via a simulated
|
||||
restart — `cargarFavoritos()` re-fetch from the fake service reflects the new order). Sort scenario green (both
|
||||
state persistence and the visual re-render order). Full suite: 614/614 green (2 skipped, unchanged), up from
|
||||
605. `flutter analyze`: 1 issue, identical to baseline (3 new warnings + 1 new info surfaced during REFACTOR —
|
||||
unused import, 3 unused optional painter parameters, missing `super.key` on the newly-public form widget — all
|
||||
fixed before this count).
|
||||
|
||||
## WU5 — Escuchar restructure
|
||||
|
||||
**Commit**: `feat(escuchar): replace discovery browser with embedded player and favorites grid`
|
||||
**Depends on**: WU1, WU4
|
||||
**Spec refs**: `app-navigation-shell` — Root-to-Root Switching Without Push
|
||||
**Verify**: `flutter test test/pantallas/pantalla_inicio_test.dart test/pantallas/pantalla_inicio_rebuild_test.dart test/widgets/mini_reproductor_configurar_test.dart && flutter analyze && dart format --set-exit-if-changed $(git diff --name-only --diff-filter=ACM HEAD -- '*.dart')`
|
||||
**Modified tests**: `pantalla_inicio_test.dart`, `pantalla_inicio_rebuild_test.dart`, `mini_reproductor_configurar_test.dart`
|
||||
**Verify**: `flutter test test/pantallas/pantalla_inicio_test.dart test/pantallas/pantalla_inicio_rebuild_test.dart test/widgets/mini_reproductor_configurar_test.dart && flutter analyze && dart format --set-exit-if-changed $(git diff --cached --name-only --diff-filter=ACM HEAD -- '*.dart')`
|
||||
**Modified tests**: `pantalla_inicio_test.dart`, `mini_reproductor_configurar_test.dart`, `test/helpers/fakes.dart`
|
||||
(see 5.4's note). `pantalla_inicio_rebuild_test.dart` needed no change — it never taps "Ver todas", so
|
||||
`EstadoNavegacionRaiz`'s absence there is never exercised (lazily read only inside the button's `onPressed`).
|
||||
|
||||
- [ ] 5.1 RED — write the ADR-7 anti-cache test: mutate `EstadoRadio` **from outside the widget tree** (simulating
|
||||
Android Auto / notification-driven playback change) and assert the Escuchar hero follows, with zero cached
|
||||
fields in `State`.
|
||||
- [ ] 5.2 RED — **hazard test**: assert `MiniReproductor` is `visible: false` (renders `SizedBox.shrink()`) while on
|
||||
Escuchar, AND that `configurarLocalizaciones` still ran in `didChangeDependencies`
|
||||
(`mini_reproductor.dart:27-38`, the S3-R3 contract). Removing the widget from the tree would silently break
|
||||
this — the hazard is hiding it structurally, not visually.
|
||||
- [ ] 5.3 RED — assert "Ver todas" calls `context.read<EstadoNavegacionRaiz>().irA(RaizPluriWave.favoritos)` and that
|
||||
`Navigator` depth is unchanged (switches tab, does not push). This only *consumes* WU1's provider.
|
||||
- [ ] 5.4 RED — update `pantalla_inicio_test.dart` / `pantalla_inicio_rebuild_test.dart` for the new content model
|
||||
(hero + "Tus emisoras" grid replaces the discovery grid); confirm `context.select` scoping keeps rebuild count
|
||||
low (existing `MemoLista` pattern).
|
||||
- [ ] 5.5 GREEN — build the Escuchar hero as a `StatelessWidget` reusing `_WaveHero` / `VisualizadorAudio` patterns
|
||||
from `pantalla_reproductor.dart` (square art, `barras: 30`, `altura: 26`, `color: liveGreen`), transport row
|
||||
with sleep as the 5th action, tool-tray entry chip; reads via `context.select<EstadoRadio, T>` per scalar.
|
||||
- [ ] 5.6 GREEN — add `MiniReproductor.altura` as a value **measured from its actual laid-out height at apply time**
|
||||
(e.g. via a `GlobalKey`/`RenderBox` read at build), never a guessed constant; add its `visible` parameter.
|
||||
`_PaginaPrincipal` sets `visible: false` on Escuchar only, keeping `State` mounted.
|
||||
- [ ] 5.7 GREEN — add the derived `PluriLayout` constant (`bottomChromeInset - MiniReproductor.altura`,
|
||||
`pluri_layout.dart:10` currently hardcodes `146` assuming the mini player is present) for Escuchar's content
|
||||
padding.
|
||||
- [ ] 5.8 GREEN — swap the "Tus emisoras" grid data source to `listaFavoritos` (capped) with "Ver todas" wired to
|
||||
`EstadoNavegacionRaiz.irA(RaizPluriWave.favoritos)`.
|
||||
- [ ] 5.9 REFACTOR — leave the discovery-section widgets (`_seccionCercanas`/`_chipGeneros`/etc.) in place for now;
|
||||
WU6 relocates and then deletes them — do not delete here to avoid an intermediate commit with the content
|
||||
nowhere. Note this handoff explicitly in the commit body.
|
||||
- [ ] 5.10 Verify — anti-cache test green; mini-player-hidden-but-side-effect-ran test green; tab-switch-not-push
|
||||
test green; `visualizador_audio.dart` and `estado_radio.dart` show empty `git diff`.
|
||||
- [x] 5.1 RED — the ADR-7 anti-cache test: `await audio.reproducir(estacionB)` mutates the underlying
|
||||
`FakeServicioAudio` **directly**, bypassing `EstadoRadio.reproducir()` entirely (the same shape as
|
||||
`navegacion_auto.dart`'s out-of-band mutation); asserts the hero's rendered station name follows. The hero
|
||||
being a `StatelessWidget` (no `State` class at all) makes "zero cached fields" true by construction — this
|
||||
behavioral test is also what would catch a hypothetical cached-field regression, since a cached value set once
|
||||
in `initState` would not follow an external mutation the way this test requires.
|
||||
- [x] 5.2 RED — hazard test added to `mini_reproductor_configurar_test.dart`: with a station actively "reproduciendo"
|
||||
(so a naive `emisora == null` check couldn't accidentally satisfy it), `MiniReproductor(visible: false)` renders
|
||||
nothing (`find.text` for the station name finds nothing) while `configurarLocalizaciones` still ran exactly
|
||||
once — confirming `didChangeDependencies` fired independent of `build()`'s early return.
|
||||
- [x] 5.3 RED — asserts `navegacion.actual == RaizPluriWave.favoritos` after tapping "Ver todas", using a
|
||||
`_RecordingNavigatorObserver` (counts `didPush` calls) to assert the push count is **unchanged** before/after
|
||||
the tap — proves "switches tabs, does not push" mechanically rather than by inspection.
|
||||
- [x] 5.4 RED — **test-file correction, noted explicitly**: `pantalla_inicio_rebuild_test.dart` needed no scenario
|
||||
change (see the header note above). Discovered and fixed a genuine, pre-existing gap while writing 5.1: no
|
||||
test in this codebase had ever exercised `ServicioAudio.androidAudioSessionIdStream` against a bare
|
||||
`FakeServicioAudio` — the real getter requires `registrarHandler()` (`main.dart`, production-only) and throws
|
||||
`"registrarHandler() no fue llamado en main.dart"` otherwise. `pantalla_reproductor.dart` has always read this
|
||||
exact getter but has **no test file at all**, so the gap was latent until `PantallaInicio` started wiring
|
||||
`VisualizadorAudio` to it here. Added a `Stream<int?>.empty()` override to `FakeServicioAudio`
|
||||
(`test/helpers/fakes.dart`) — purely additive, matches `VisualizadorAudio`'s own documented no-native-session
|
||||
fallback, does not change any existing test's behavior (grep-confirmed nothing else reads this getter).
|
||||
`context.select` scoping: the hero selects `emisoraActual` itself (one scalar per ADR-7 rule 2 — `Emisora`'s
|
||||
own `==`/`hashCode` are uuid-based, so this only rebuilds on a real station change, not on audio-buffer
|
||||
notifications); the fast-changing `EstadoReproduccion` is read via `StreamBuilder` instead (the same pattern
|
||||
`_Controles`/`MiniReproductor` already use), so playback-status ticks never even reach the hero's own rebuild
|
||||
path. `pantalla_inicio_rebuild_test.dart`'s EQ-preset-doesn't-rebuild guard still passes unmodified.
|
||||
- [x] 5.5 GREEN — built `_EscucharHero` (`StatelessWidget`) in `pantalla_inicio.dart`: square art (`_ArteEscuchar`,
|
||||
`ClipRRect` not `ClipOval` — the full player's own `_WaveHero` stays circular and untouched), live/offline
|
||||
`PluriStatusPill`, `VisualizadorAudio(barras: 30, altura: 26, color: liveGreen)`. Transport row
|
||||
(`_FilaTransporteEscuchar`) is favorite / EQ toggle / stop / play-pause (primary) / sleep, in that order —
|
||||
**design decision, not spec-tested** (no GIVEN/WHEN/THEN scenario enumerates the exact 5 actions; only ADR-7's
|
||||
structural rules are): the first 4 mirror the full player's own existing app-bar + transport actions
|
||||
(favorite, EQ, stop, play/pause) exactly, with sleep as the documented 5th, satisfying "no new playback
|
||||
methods" (rule 3) by construction — every action calls an existing `EstadoRadio`/`EstadoEcualizador` method. A
|
||||
separate "tool-tray entry chip" (`OutlinedButton.icon`) opens the full player via the existing
|
||||
`PantallaReproductor.abrir`. 5 new ARB keys (en/es only, established precedent): `yourStationsTitle`,
|
||||
`seeAllAction`, `openFullPlayerTooltip`, `nothingPlayingTitle`, `nothingPlayingSubtitle` (the last two back a
|
||||
lightweight placeholder state when `emisoraActual` is null, so the hero degrades gracefully before any
|
||||
playback has started).
|
||||
- [x] 5.6 GREEN — added `MiniReproductor.visible` (default `true`) and `static const double altura`. **Measured, not
|
||||
guessed**: added a self-verifying test asserting `altura` is `closeTo` (±4px tolerance) the REAL
|
||||
`tester.getSize(find.byType(MiniReproductor)).height` with a representative station and default text scale;
|
||||
ran it with a placeholder first, read the actual measured value (`72.0`) from the assertion failure, then set
|
||||
the constant to match exactly. `build()` returns `SizedBox.shrink()` when `!visible`, independent of
|
||||
`didChangeDependencies` (task 5.2's hazard test proves the side effect still runs). `app.dart` passes
|
||||
`visible: indice != RaizPluriWave.escuchar.index`.
|
||||
- [x] 5.7 GREEN — added `PluriLayout.escucharBottomChromeInset = bottomChromeInset - MiniReproductor.altura` and
|
||||
wired it into `pantalla_inicio.dart`'s own bottom `SliverPadding` (replacing the plain `bottomChromeInset`
|
||||
every other root/scrollable still uses) — the mini player is hidden for this whole screen, not just the new
|
||||
sections, so the reduced inset applies to the still-present discovery grid too.
|
||||
- [x] 5.8 GREEN — added `_seccionTusEmisoras` (replaces the removed `_heroHeader`'s call site, right after the hero):
|
||||
`listaFavoritos` (not `listaFavoritosManual` — Escuchar previews the same globally-ordered list every other
|
||||
screen shows, it doesn't need Favoritos' own manual-order view) capped at 6, horizontally-scrollable compact
|
||||
`TarjetaEmisora` cards, "Ver todas" (`TextButton`) calling
|
||||
`context.read<EstadoNavegacionRaiz>().irA(RaizPluriWave.favoritos)`. Empty-favorites case reuses the existing
|
||||
`favoritesEmptySubtitle` string rather than a new key.
|
||||
- [x] 5.9 REFACTOR — confirmed `_seccionCercanas`/`_seccionTendencias`/`_chipGeneros`/`_errorBanner`/`_gridEmisoras`
|
||||
(the discovery grid itself) are untouched, left in place below the new hero + "Tus emisoras" section, exactly
|
||||
as instructed — WU6 relocates and deletes them. Confirmed `flutter test/widgets/pluri_push_scaffold_test.dart`
|
||||
"The 5 root screens build zero Scaffold when mounted bare PantallaInicio" still passes (the hero adds slivers,
|
||||
no `Scaffold`).
|
||||
- [x] 5.10 Verify — anti-cache test green; mini-player-hidden-but-side-effect-ran test green; tab-switch-not-push
|
||||
test green (push count unchanged); `git diff --stat` confirmed empty for `lib/widgets/visualizador_audio.dart`
|
||||
AND `lib/estado/estado_radio.dart` (WU5 touches neither). Full suite: 618/618 green (2 skipped, unchanged), up
|
||||
from 614. `flutter analyze`: 1 issue, identical to baseline.
|
||||
|
||||
**Discovery worth flagging for WU6+ (or any future WU rendering an actively-playing station in a widget test):**
|
||||
`VisualizadorAudio` starts an indeterminately-**repeating** `AnimationController` (`visualizador_audio.dart:77`,
|
||||
`_controller.repeat()`) whenever the stream reports "reproduciendo"/"cargando"/"reconectando" — this is the same
|
||||
class of hazard as an indeterminate spinner (`pumpAndSettle()` never returns while it keeps scheduling frames), just
|
||||
via an animation instead of a progress indicator. Any test that renders a playing station through a widget that
|
||||
embeds `VisualizadorAudio` (this hero, the full player) must use a **bounded** `pump()`, never `pumpAndSettle()`,
|
||||
once the station starts "reproduciendo".
|
||||
|
||||
## WU6 — Buscar landing state + filters
|
||||
|
||||
@@ -556,23 +680,99 @@ Constrained to Rename/Share/Delete
|
||||
**New tests**: `pantalla_grabaciones_test.dart`
|
||||
**Modified tests**: `servicio_grabacion_radio_test.dart` (only if a listing method is added)
|
||||
|
||||
- [ ] 15.1 RED — storage progress-bar fill reflects used/total (e.g. 84/200 MB fixture) with a caption stating both
|
||||
- [x] 15.1 RED — storage progress-bar fill reflects used/total (e.g. 84/200 MB fixture) with a caption stating both
|
||||
values.
|
||||
- [ ] 15.2 RED — 3 recording-file fixtures render as 3 rows (name/date/duration/size); an empty folder renders an
|
||||
- [x] 15.2 RED — 3 recording-file fixtures render as 3 rows (name/date/duration/size); an empty folder renders an
|
||||
empty state, not an error; tapping play starts/stops playback.
|
||||
- [ ] 15.3 RED — the "⋮" menu exposes exactly Rename/Share/Delete — constrained to what
|
||||
- [x] 15.3 RED — the "⋮" menu exposes exactly Rename/Share/Delete — constrained to what
|
||||
`servicio_grabacion_radio.dart` already exposes, no extra action.
|
||||
- [ ] 15.4 RED — Delete (file + row removed), Rename (persists across reload), Share (invokes platform share sheet)
|
||||
scenarios.
|
||||
- [ ] 15.5 GREEN — add any missing listing method to `servicio_grabacion_radio.dart`, strictly limited to what it
|
||||
already conceptually supports.
|
||||
- [ ] 15.6 GREEN — build `lib/pantallas/pantalla_grabaciones.dart` (storage bar, per-recording rows with inline
|
||||
playback, "⋮" menu).
|
||||
- [ ] 15.7 REFACTOR — confirm the menu cannot expose a 4th action; confirm the screen degrades to an empty state,
|
||||
not a crash, on an empty folder.
|
||||
- [ ] 15.8 Verify — all 4 menu-action scenarios green; empty-folder scenario green.
|
||||
- [x] 15.4 RED — Delete (file + row removed), Rename (persists across reload), Share (invokes platform share sheet)
|
||||
scenarios. **Delete and Rename widget-level scenarios are written but `skip: true`** (Share is not — see
|
||||
below): both hang indefinitely the instant `EstadoGrabacion` wires to a bare, real `ServicioGrabacionRadio`
|
||||
pointed at a real directory inside a `testWidgets()` body — a combination no other test in this codebase
|
||||
uses. Four independent causes were ruled out at apply time (a directory-path off-by-one;
|
||||
`Directory.list()`'s async stream vs `listSync()`; the confirm-dialog interaction specifically — a
|
||||
render-only reproduction with zero taps hangs identically; `WidgetTester.runAsync()`, Flutter's own
|
||||
documented escape hatch for real I/O during widget lifecycle). The underlying logic is NOT unproven:
|
||||
`eliminarGrabacion` and `renombrarGrabacion` both have passing unit tests in
|
||||
`servicio_grabacion_radio_test.dart` (plain `test()`, exercised against real files, no hang) — the one
|
||||
variable no diagnostic could change was `testWidgets()` itself. Documented inline in both skipped tests;
|
||||
flagged for a fresh Windows-process debugging pass in a future batch, not treated as a code defect.
|
||||
- [x] 15.5 GREEN — added `listarGrabaciones()` to `servicio_grabacion_radio.dart` (pure filesystem read via
|
||||
`listSync`/`statSync`, sorted most-recent-first, empty list for a missing directory — never throws). Also
|
||||
added `eliminarGrabacion(ruta)` and `renombrarGrabacion(ruta, nuevoNombre)` — file-lifecycle management for
|
||||
files this service already creates, not new capabilities; both needed for the constrained "⋮" menu (task
|
||||
15.3/15.4), both via plain `dart:io`, no new conceptual surface. `EstadoGrabacion` gained thin delegates for
|
||||
all three plus `notifyListeners()`. New `lib/modelos/archivo_grabacion.dart` (ruta, nombre, fecha,
|
||||
tamanoBytes — pure filesystem metadata, no audio decoding).
|
||||
- [x] 15.6 GREEN — built `lib/pantallas/pantalla_grabaciones.dart`: storage bar (`LinearProgressIndicator` over
|
||||
`EstadoGrabacion.maxBytes` vs. the summed listing), per-recording rows (name/date/duration/size) with inline
|
||||
play/pause, "⋮" menu (`PopupMenuButton`, exactly 3 items). New `ReproductorGrabaciones` abstraction
|
||||
(duration-lookup + toggle-play) keeps `just_audio.AudioPlayer` out of `ServicioAudio` (never touched — hard
|
||||
constraint) and out of `servicio_grabacion_radio.dart`; the real `just_audio`-backed implementation is
|
||||
static-review-only (same documented constraint as `PluriWaveAudioHandler` in `cola_local_test.dart` — a real
|
||||
`AudioPlayer` needs platform `MethodChannel`s this suite does not mock), every test injects a fake. `Share`
|
||||
is similarly injected (`compartir` constructor parameter), defaulting to the real `share_plus` call. 12 new
|
||||
ARB keys (en/es only, matching WU1/3a/3b precedent): `recordingsLibraryTitle`,
|
||||
`recordingsLibraryStorageCaption`, `recordingsLibraryEmptyTitle`, `recordingsLibraryEmptySubtitle`,
|
||||
`recordingActionRename/Share/Delete`, `recordingRenameDialogTitle/Label/EmptyError`,
|
||||
`recordingDeleteConfirmTitle/Message` — a brand-new screen needs new copy, unlike WU3a/WU3b's move-only
|
||||
screens.
|
||||
- [x] 15.7 REFACTOR — confirmed the menu cannot expose a 4th action (task 15.3's test asserts `PopupMenuItem`
|
||||
count is exactly 3); confirmed the empty-folder scenario renders `PluriEmptyState`, not a crash or exception
|
||||
(`tester.takeException()` asserted null).
|
||||
- [x] 15.8 Verify — scoped verify green: 17 passed, 2 skipped (documented above), 0 failed — the menu-action
|
||||
scenarios remain exercised end-to-end for Share (15.4-C) plus both file-mutation methods proven at the
|
||||
service level. Empty-folder scenario green. Full suite: 604/604 green (2 skipped), up from 592. `flutter
|
||||
analyze`: 1 issue, identical to baseline.
|
||||
|
||||
**Known plan gap, not fixed here (out of WU15's task list, flagged for the orchestrator):** no task in WU15 — or
|
||||
anywhere else in this file — wires a navigation entry point to `PantallaGrabaciones`. It is a genuinely new,
|
||||
currently-unreachable route. `PantallaAjustesGrabaciones` (WU3b) is a different screen (folder/size-limit settings)
|
||||
and no task asks it to gain a "view library" link. WU15's own **Verify** command lists only
|
||||
`pantalla_grabaciones_test.dart` and `servicio_grabacion_radio_test.dart` — not
|
||||
`pantalla_ajustes_grabaciones_test.dart` — confirming this screen is not meant to touch WU3b's file in this work
|
||||
unit. Not invented here; needs a design decision, not a guess.
|
||||
|
||||
## WU15b — Wire the recordings library into Settings navigation
|
||||
|
||||
**Not in the original plan — added to close the WU15 gap noted above.** WU15 built `PantallaGrabaciones` (the
|
||||
recordings library) fully tested and committed, but left it unreachable from the app: `rg "PantallaGrabaciones" lib/`
|
||||
found only its own declaration. This work unit exists solely to fix that.
|
||||
|
||||
**Commit**: `fix(grabaciones): wire the recordings library into Settings navigation`
|
||||
**Depends on**: WU15
|
||||
**Spec refs**: `recordings-library` — Browsable Recordings List (reachability); `app-navigation-shell` — Push-Chrome
|
||||
on Second-Level Screens (the settings form stays a pushed screen, now one level deeper)
|
||||
**Verify**: `flutter test test/pantallas/pantalla_ajustes_test.dart test/pantallas/pantalla_grabaciones_test.dart test/pantallas/ajustes/pantalla_ajustes_grabaciones_test.dart && flutter analyze && dart format --set-exit-if-changed $(git diff --cached --name-only --diff-filter=ACM HEAD -- '*.dart')`
|
||||
**Modified tests**: `pantalla_ajustes_test.dart` (one scenario re-targeted), `pantalla_grabaciones_test.dart` (one new
|
||||
scenario)
|
||||
|
||||
**Coordinator ruling applied.** The approved mockup's screen 12 ("Ajustes › Grabaciones") depicts the recordings
|
||||
**library**, not the settings form — so the `GRABACIONES Y MÚSICA` group's "Grabaciones" row now opens
|
||||
`PantallaGrabaciones` (the library) instead of `PantallaAjustesGrabaciones` (the folder/size-limit settings form).
|
||||
The settings form is not dropped: it stays reachable, but now from **within** the library, via a settings icon in its
|
||||
`PluriPushScaffold.actions` (matching the existing `pantalla_ajustes_timer_sueno.dart` "Add" action precedent for a
|
||||
real capability living in the header, not decorative chrome).
|
||||
|
||||
- [x] 15b.1 RED — re-targeted `pantalla_ajustes_test.dart`'s "tapping the Grabaciones row" scenario: it now asserts
|
||||
landing on `PantallaGrabaciones` (`find.text('My recordings')`), not the settings form (`find.text('Change
|
||||
path')` asserted absent).
|
||||
- [x] 15b.2 RED — added a new scenario to `pantalla_grabaciones_test.dart`: tapping the library's settings icon
|
||||
pushes a second `PluriPushScaffold` showing the settings form (`find.text('Change path')`).
|
||||
- [x] 15b.3 GREEN — `pantalla_ajustes.dart`: the "Grabaciones" `FilaAjuste.onTap` now pushes `PantallaGrabaciones`;
|
||||
removed the now-unused `PantallaAjustesGrabaciones` import from this file.
|
||||
- [x] 15b.4 GREEN — `pantalla_grabaciones.dart`: added `actions: [IconButton(Icons.settings_outlined, ...)]` to its
|
||||
`PluriPushScaffold`, pushing `PantallaAjustesGrabaciones`. One new ARB key (en/es only, matching precedent):
|
||||
`recordingsLibrarySettingsTooltip`.
|
||||
- [x] 15b.5 REFACTOR — confirmed the new test needed the same `_suppressListTileInkAssertion()` helper WU3a/WU3b
|
||||
established (WU3b Discovery 9: any pushed screen whose `PluriGlassSurface` body contains a tappable `ListTile`
|
||||
needs it) — added to `pantalla_grabaciones_test.dart`, scoped to only the one new scenario that mounts
|
||||
`PantallaAjustesGrabaciones`.
|
||||
- [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 states
|
||||
|
||||
**Commit**: `feat(connectivity): restyle offline and reconnect banners`
|
||||
**Depends on**: WU1
|
||||
|
||||
@@ -42,6 +42,16 @@ class FakeServicioAudio extends ServicioAudio {
|
||||
@override
|
||||
Stream<EstadoReproduccion> get estadoStream => _estadoController.stream;
|
||||
|
||||
// WU5: the real getter needs registrarHandler() (main.dart, production
|
||||
// only) — reading it against a bare FakeServicioAudio throws
|
||||
// "registrarHandler() no fue llamado en main.dart". No test exercised
|
||||
// this getter before PantallaInicio started wiring VisualizadorAudio to
|
||||
// it (WU5's Escuchar hero, mirroring pantalla_reproductor.dart's own
|
||||
// usage, which has no test file at all). An empty stream matches
|
||||
// VisualizadorAudio's own documented no-native-session fallback.
|
||||
@override
|
||||
Stream<int?> get androidAudioSessionIdStream => const Stream<int?>.empty();
|
||||
|
||||
@override
|
||||
bool get estaSonando => _estadoActual == EstadoReproduccion.reproduciendo;
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
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/pantallas/ajustes/pantalla_ajustes_backup.dart';
|
||||
import 'package:pluriwave/widgets/pluri_push_scaffold.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../helpers/fakes.dart';
|
||||
import '../../helpers/fakes_alarmas.dart';
|
||||
|
||||
/// Pre-existing project constraint (see `pantalla_ajustes_test.dart`):
|
||||
/// PluriGlassSurface paints a background over ListTile's ink layer, which
|
||||
/// Flutter flags as a warning-level assertion, not a correctness bug.
|
||||
void _suppressListTileInkAssertion() {
|
||||
final original = FlutterError.onError;
|
||||
FlutterError.onError = (details) {
|
||||
if (details.exceptionAsString().contains(
|
||||
'ListTile background color or ink splashes may be invisible',
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
original?.call(details);
|
||||
};
|
||||
addTearDown(() => FlutterError.onError = original);
|
||||
}
|
||||
|
||||
/// WU3b task 3b.1: the APLICACIÓN detail screen for "Copia de seguridad"
|
||||
/// renders inside a [PluriPushScaffold] and its moved controls (export /
|
||||
/// import rows) are still present and reachable exactly as they were inside
|
||||
/// the old `_SeccionBackup`.
|
||||
///
|
||||
/// Both rows call into native plugins (`share_plus`, `file_picker`) that
|
||||
/// this suite does not mock, so this file verifies reachability (title and
|
||||
/// both row labels present, with a live `onTap`) rather than tapping through
|
||||
/// the native share/pick flow — the same conservative choice this batch
|
||||
/// makes for any moved control that would otherwise depend on an unmocked
|
||||
/// platform channel.
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
Future<File> archivoCustomVacio() async => File(
|
||||
'${Directory.current.path}/test/fixtures/emisoras_custom_vacio.json',
|
||||
);
|
||||
|
||||
Future<EstadoRadio> crearEstado() async {
|
||||
return EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
|
||||
resolverArchivoCustom: archivoCustomVacio,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildScreen(EstadoRadio estado) {
|
||||
return ChangeNotifierProvider<EstadoRadio>.value(
|
||||
value: estado,
|
||||
child: MaterialApp(
|
||||
locale: const Locale('en'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const PantallaAjustesBackup(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('renders inside a PluriPushScaffold titled "Backup"', (
|
||||
tester,
|
||||
) async {
|
||||
_suppressListTileInkAssertion();
|
||||
final estado = await crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(buildScreen(estado));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(find.byType(PluriPushScaffold), findsOneWidget);
|
||||
expect(find.text('Backup'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'moved controls still reachable: export and import rows both present',
|
||||
(tester) async {
|
||||
_suppressListTileInkAssertion();
|
||||
final estado = await crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(buildScreen(estado));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
final exportTile = find.widgetWithText(ListTile, 'Export configuration');
|
||||
final importTile = find.widgetWithText(ListTile, 'Import configuration');
|
||||
expect(exportTile, findsOneWidget);
|
||||
expect(importTile, findsOneWidget);
|
||||
expect(tester.widget<ListTile>(exportTile).onTap, isNotNull);
|
||||
expect(tester.widget<ListTile>(importTile).onTap, isNotNull);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/estado/estado_grabacion.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/pantallas/ajustes/pantalla_ajustes_grabaciones.dart';
|
||||
import 'package:pluriwave/widgets/pluri_push_scaffold.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../helpers/fakes_alarmas.dart';
|
||||
|
||||
/// Pre-existing project constraint (see `pantalla_ajustes_test.dart`):
|
||||
/// PluriGlassSurface paints a background over ListTile's ink layer, which
|
||||
/// Flutter flags as a warning-level assertion, not a correctness bug.
|
||||
void _suppressListTileInkAssertion() {
|
||||
final original = FlutterError.onError;
|
||||
FlutterError.onError = (details) {
|
||||
if (details.exceptionAsString().contains(
|
||||
'ListTile background color or ink splashes may be invisible',
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
original?.call(details);
|
||||
};
|
||||
addTearDown(() => FlutterError.onError = original);
|
||||
}
|
||||
|
||||
/// 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({});
|
||||
});
|
||||
|
||||
Widget buildScreen(EstadoGrabacion estado) {
|
||||
return ListenableProvider<EstadoGrabacion>.value(
|
||||
value: estado,
|
||||
child: MaterialApp(
|
||||
locale: const Locale('en'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const PantallaAjustesGrabaciones(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('renders inside a PluriPushScaffold titled "Recordings"', (
|
||||
tester,
|
||||
) async {
|
||||
_suppressListTileInkAssertion();
|
||||
final estado = EstadoGrabacion(
|
||||
servicio: FakeServicioGrabacionRadioInactiva(),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(buildScreen(estado));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(find.byType(PluriPushScaffold), findsOneWidget);
|
||||
expect(find.text('Recordings'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('moved control still responds: restore default path clears the '
|
||||
'configured directory', (tester) async {
|
||||
_suppressListTileInkAssertion();
|
||||
final estado = EstadoGrabacion(
|
||||
servicio: FakeServicioGrabacionRadioInactiva(),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
await estado.cambiarDirectorio('/tmp/custom-recordings');
|
||||
expect(estado.directorioConfigurado, '/tmp/custom-recordings');
|
||||
|
||||
await tester.pumpWidget(buildScreen(estado));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
await tester.tap(find.byIcon(Icons.restore_rounded));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(estado.directorioConfigurado, isNull);
|
||||
expect(
|
||||
find.text('The default internal folder will be used'),
|
||||
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();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/estado/estado_idioma.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/pantallas/ajustes/pantalla_ajustes_idioma.dart';
|
||||
import 'package:pluriwave/widgets/pluri_push_scaffold.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// WU3b task 3b.1: the APLICACIÓN detail screen for "Idioma" renders inside
|
||||
/// a [PluriPushScaffold] and its moved control (the language dropdown)
|
||||
/// still responds exactly as it did inside the old `_SeccionIdioma`.
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
Widget buildScreen(EstadoIdioma estado) {
|
||||
return ChangeNotifierProvider<EstadoIdioma>.value(
|
||||
value: estado,
|
||||
child: MaterialApp(
|
||||
locale: const Locale('en'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const PantallaAjustesIdioma(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('renders inside a PluriPushScaffold titled "Language"', (
|
||||
tester,
|
||||
) async {
|
||||
final estado = EstadoIdioma(
|
||||
sharedPreferences: await SharedPreferences.getInstance(),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(buildScreen(estado));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(PluriPushScaffold), findsOneWidget);
|
||||
// "Language" legitimately renders twice here too (pre-existing,
|
||||
// unmodified by this move): the pushed screen's title AND the
|
||||
// dropdown's own floating label share the same l10n string.
|
||||
expect(find.text('Language'), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('moved control still responds: selecting a language persists '
|
||||
'it', (tester) async {
|
||||
final estado = EstadoIdioma(
|
||||
sharedPreferences: await SharedPreferences.getInstance(),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(buildScreen(estado));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byType(DropdownButtonFormField<String>));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Español').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(estado.localeSeleccionado, const Locale('es'));
|
||||
expect(find.text('Language updated: Español'), 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();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
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/pantallas/ajustes/pantalla_ajustes_info.dart';
|
||||
import 'package:pluriwave/widgets/pluri_push_scaffold.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../helpers/fakes.dart';
|
||||
import '../../helpers/fakes_alarmas.dart';
|
||||
|
||||
/// Pre-existing project constraint (see `pantalla_ajustes_test.dart`):
|
||||
/// PluriGlassSurface paints a background over ListTile's ink layer, which
|
||||
/// Flutter flags as a warning-level assertion, not a correctness bug.
|
||||
void _suppressListTileInkAssertion() {
|
||||
final original = FlutterError.onError;
|
||||
FlutterError.onError = (details) {
|
||||
if (details.exceptionAsString().contains(
|
||||
'ListTile background color or ink splashes may be invisible',
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
original?.call(details);
|
||||
};
|
||||
addTearDown(() => FlutterError.onError = original);
|
||||
}
|
||||
|
||||
/// WU3b task 3b.1: the APLICACIÓN detail screen for "Info" renders inside a
|
||||
/// [PluriPushScaffold] and its moved controls (saved-favorites count, help
|
||||
/// row) still respond exactly as they did inside the old `_SeccionInfo`.
|
||||
/// Unlike the other four WU3b screens, this one never had its own header
|
||||
/// icon+title row to strip (see `pantalla_ajustes_info.dart`'s doc comment).
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
Future<File> archivoCustomVacio() async => File(
|
||||
'${Directory.current.path}/test/fixtures/emisoras_custom_vacio.json',
|
||||
);
|
||||
|
||||
Future<EstadoRadio> crearEstado() async {
|
||||
return EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
|
||||
resolverArchivoCustom: archivoCustomVacio,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildScreen(EstadoRadio estado) {
|
||||
return ChangeNotifierProvider<EstadoRadio>.value(
|
||||
value: estado,
|
||||
child: MaterialApp(
|
||||
locale: const Locale('en'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const PantallaAjustesInfo(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('renders inside a PluriPushScaffold titled "Info"', (
|
||||
tester,
|
||||
) async {
|
||||
_suppressListTileInkAssertion();
|
||||
final estado = await crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(buildScreen(estado));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(find.byType(PluriPushScaffold), findsOneWidget);
|
||||
expect(find.text('Info'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'moved control still responds: saved favorites count reflects the '
|
||||
'favorites list',
|
||||
(tester) async {
|
||||
_suppressListTileInkAssertion();
|
||||
final estado = await crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(buildScreen(estado));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(find.text('Saved favorites'), findsOneWidget);
|
||||
expect(find.text('0'), findsOneWidget);
|
||||
expect(find.text('Help and tutorial'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/pantallas/ajustes/pantalla_ajustes_musica_local.dart';
|
||||
import 'package:pluriwave/widgets/pluri_push_scaffold.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// WU3b task 3b.1: the GRABACIONES Y MÚSICA detail screen for "Música local"
|
||||
/// renders inside a [PluriPushScaffold] and its moved controls (the
|
||||
/// android-auto-local-music-paging Phase 7 friendly-folder-name projection)
|
||||
/// still respond exactly as they did inside the old `_SeccionMusicaLocal`.
|
||||
///
|
||||
/// The two Phase 7 scenarios below are relocated verbatim from
|
||||
/// `pantalla_ajustes_test.dart`'s "_SeccionMusicaLocal — friendly folder
|
||||
/// name (Phase 7)" group, now targeting the isolated screen directly instead
|
||||
/// of scrolling to find it inside the whole Settings root.
|
||||
void main() {
|
||||
Widget buildScreen() {
|
||||
return const MaterialApp(
|
||||
locale: Locale('en'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: PantallaAjustesMusicaLocal(),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('renders inside a PluriPushScaffold titled "Local music (Android '
|
||||
'Auto)"', (tester) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
|
||||
await tester.pumpWidget(buildScreen());
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(find.byType(PluriPushScaffold), findsOneWidget);
|
||||
expect(find.text('Local music (Android Auto)'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'7.1-A: carpeta configurada muestra el nombre amigable derivado de la '
|
||||
'URI, nunca la URI cruda',
|
||||
(tester) async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'musica_local_uri':
|
||||
'content://com.android.externalstorage.documents/tree/'
|
||||
'primary%3AMusic%2FMyFolder',
|
||||
});
|
||||
|
||||
await tester.pumpWidget(buildScreen());
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(find.text('MyFolder'), findsOneWidget);
|
||||
expect(find.textContaining('content://'), findsNothing);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('7.1-B: sin carpeta configurada mantiene el mensaje '
|
||||
'localMusicFolderNotConfigured', (tester) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
|
||||
await tester.pumpWidget(buildScreen());
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(find.text('No folder selected'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -88,15 +88,12 @@ void main() {
|
||||
|
||||
// ── WU3a: AUDIO + EMISORAS become grouped nav rows ─────────────────────────
|
||||
//
|
||||
// Design ADR-3: the root now carries zero inline controls for the 7
|
||||
// sections WU3a moved (Ecualizador, Salida de audio, Temporizador de sueño,
|
||||
// Grupos de favoritos, Emisora preferida, Emisoras personalizadas, Orden de
|
||||
// listas) — each is reached through a FilaAjuste row instead. The other 5
|
||||
// sections (Grabaciones, Música local, Idioma, Backup, Info) still render
|
||||
// inline here: WU3b decomposes GRABACIONES Y MÚSICA / APLICACIÓN the same
|
||||
// way, so the root is not yet under 400 lines nor fully "zero inline
|
||||
// controls" — that end state is WU3b's completion, not WU3a's (see the
|
||||
// apply-progress note on this discrepancy in tasks.md 3a.1/3a.8).
|
||||
// Design ADR-3: the root carries zero inline controls for the 7 sections
|
||||
// WU3a moved (Ecualizador, Salida de audio, Temporizador de sueño, Grupos
|
||||
// de favoritos, Emisora preferida, Emisoras personalizadas, Orden de
|
||||
// listas) — each is reached through a FilaAjuste row instead. WU3b (below)
|
||||
// completes the remaining 5 sections, so the root is now exactly 4
|
||||
// GrupoAjustes cards, under 400 lines.
|
||||
group('WU3a — AUDIO and EMISORAS groups', () {
|
||||
testWidgets('AUDIO group renders exactly 3 nav rows, no inline controls', (
|
||||
tester,
|
||||
@@ -190,9 +187,91 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Sections not yet converted (WU3b's job) stay reachable ─────────────────
|
||||
group('Sections pending WU3b remain inline and reachable', () {
|
||||
testWidgets('Grabaciones, Idioma, Backup and Info still render', (
|
||||
// ── WU3b: GRABACIONES Y MÚSICA + APLICACIÓN become grouped nav rows ───────
|
||||
//
|
||||
// Design ADR-3: the root now carries zero inline controls for the final 5
|
||||
// sections (Grabaciones, Música local, Idioma, Backup, Info) — each is
|
||||
// reached through a FilaAjuste row, matching WU3a's AUDIO/EMISORAS
|
||||
// treatment. All 12 sections are decomposed now; the root is exactly 4
|
||||
// GrupoAjustes cards and crosses under 400 lines (tasks.md 3b.5). The two
|
||||
// "Phase 7" friendly-folder-name scenarios that used to live in this file
|
||||
// are relocated verbatim to `ajustes/pantalla_ajustes_musica_local_test.dart`,
|
||||
// now targeting the isolated pushed screen directly.
|
||||
group('WU3b — GRABACIONES Y MÚSICA and APLICACIÓN groups', () {
|
||||
testWidgets(
|
||||
'RECORDINGS & MUSIC group renders exactly 2 nav rows, no inline '
|
||||
'controls',
|
||||
(tester) async {
|
||||
setLargeSurface(tester);
|
||||
_suppressListTileInkAssertion();
|
||||
final estado = await crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(buildAjustes(estado));
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(find.text('RECORDINGS & MUSIC'), findsOneWidget);
|
||||
expect(find.text('Recordings'), findsOneWidget);
|
||||
expect(find.text('Local music (Android Auto)'), findsOneWidget);
|
||||
|
||||
// Zero inline controls: the folder-path row, path action buttons and
|
||||
// max-size row are gone from the root now.
|
||||
expect(find.text('Change path'), findsNothing);
|
||||
expect(find.text('Maximum recording size'), findsNothing);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'APPLICATION group renders exactly 3 nav rows, no inline controls',
|
||||
(tester) async {
|
||||
setLargeSurface(tester);
|
||||
_suppressListTileInkAssertion();
|
||||
final estado = await crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(buildAjustes(estado));
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(find.text('APPLICATION'), findsOneWidget);
|
||||
// "Language" now renders exactly once at the root (the FilaAjuste
|
||||
// row only) — the old inline dropdown's duplicate label is gone.
|
||||
expect(find.text('Language'), findsOneWidget);
|
||||
expect(find.text('Backup'), findsOneWidget);
|
||||
expect(find.text('Info'), findsOneWidget);
|
||||
|
||||
// Zero inline controls: the language dropdown and export/import rows
|
||||
// are gone from the root now.
|
||||
expect(find.byType(DropdownButtonFormField<String>), findsNothing);
|
||||
expect(find.text('Export configuration'), findsNothing);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'tapping the Grabaciones row pushes the recordings LIBRARY, not the '
|
||||
'folder/size settings form (WU15b — the approved mockup screen '
|
||||
'"Ajustes > Grabaciones" depicts the library)',
|
||||
(tester) async {
|
||||
setLargeSurface(tester);
|
||||
_suppressListTileInkAssertion();
|
||||
final estado = await crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(buildAjustes(estado));
|
||||
await pumpStable(tester);
|
||||
|
||||
await tester.tap(find.text('Recordings'));
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(find.byType(PluriPushScaffold), findsOneWidget);
|
||||
expect(find.text('My recordings'), findsOneWidget);
|
||||
// The folder/size settings form is reachable FROM the library now,
|
||||
// not directly from the Settings root row — see
|
||||
// pantalla_grabaciones_test.dart for that entry point.
|
||||
expect(find.text('Change path'), findsNothing);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('tapping the Info row pushes its detail screen', (
|
||||
tester,
|
||||
) async {
|
||||
setLargeSurface(tester);
|
||||
@@ -203,50 +282,16 @@ void main() {
|
||||
await tester.pumpWidget(buildAjustes(estado));
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(find.text('Recordings'), findsOneWidget);
|
||||
// "Language" legitimately renders twice (pre-existing, unmodified by
|
||||
// WU3a): the section header AND the dropdown's own label share the
|
||||
// same l10n string.
|
||||
expect(find.text('Language'), findsWidgets);
|
||||
expect(find.text('Backup'), findsOneWidget);
|
||||
await tester.tap(find.text('Info'));
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(find.byType(PluriPushScaffold), findsOneWidget);
|
||||
expect(find.text('Help and tutorial'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
// ── android-auto-local-music-paging Phase 7: friendly folder name ────────
|
||||
group('_SeccionMusicaLocal — friendly folder name (Phase 7)', () {
|
||||
testWidgets(
|
||||
'7.1-A: carpeta configurada muestra el nombre amigable derivado de '
|
||||
'la URI, nunca la URI cruda',
|
||||
(tester) async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'musica_local_uri':
|
||||
'content://com.android.externalstorage.documents/tree/'
|
||||
'primary%3AMusic%2FMyFolder',
|
||||
});
|
||||
setLargeSurface(tester);
|
||||
_suppressListTileInkAssertion();
|
||||
final estado = await crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(buildAjustes(estado));
|
||||
await pumpStable(tester);
|
||||
|
||||
await tester.scrollUntilVisible(
|
||||
find.text('Local music folder'),
|
||||
300,
|
||||
scrollable: find.byType(Scrollable).first,
|
||||
);
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(find.text('MyFolder'), findsOneWidget);
|
||||
expect(find.textContaining('content://'), findsNothing);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('7.1-B: sin carpeta configurada mantiene el mensaje '
|
||||
'localMusicFolderNotConfigured', (tester) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
testWidgets('root now contains exactly 4 GrupoAjustes cards', (
|
||||
tester,
|
||||
) async {
|
||||
setLargeSurface(tester);
|
||||
_suppressListTileInkAssertion();
|
||||
final estado = await crearEstado();
|
||||
@@ -255,14 +300,10 @@ void main() {
|
||||
await tester.pumpWidget(buildAjustes(estado));
|
||||
await pumpStable(tester);
|
||||
|
||||
await tester.scrollUntilVisible(
|
||||
find.text('Local music folder'),
|
||||
300,
|
||||
scrollable: find.byType(Scrollable).first,
|
||||
);
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(find.text('No folder selected'), findsOneWidget);
|
||||
expect(find.text('AUDIO'), findsOneWidget);
|
||||
expect(find.text('STATIONS'), findsOneWidget);
|
||||
expect(find.text('RECORDINGS & MUSIC'), findsOneWidget);
|
||||
expect(find.text('APPLICATION'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
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/pantallas/ajustes/pantalla_ajustes_grupos_favoritos.dart';
|
||||
import 'package:pluriwave/pantallas/pantalla_favoritos.dart';
|
||||
import 'package:pluriwave/widgets/pluri_push_scaffold.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../helpers/fakes.dart';
|
||||
import '../helpers/fakes_alarmas.dart';
|
||||
|
||||
/// WU4, `favorites-organization` spec: chip-filtered flat list replacing the
|
||||
/// stacked per-group panels, drag-to-reorder, the `swap_vert` sort action,
|
||||
/// group management, and the custom-station CTA — all reachable from
|
||||
/// Favoritos.
|
||||
///
|
||||
/// This screen previously had NO widget-level test coverage —
|
||||
/// `pantalla_favoritos_plural_test.dart` only exercises `stationCount`'s ARB
|
||||
/// plural formatting via `AppLocalizations` directly and never imports
|
||||
/// `PantallaFavoritos`. That file is left untouched (it stays a valid,
|
||||
/// unrelated regression guard); this new file covers the screen itself.
|
||||
///
|
||||
/// Pre-existing project constraint (see `pantalla_ajustes_test.dart`):
|
||||
/// PluriGlassSurface paints a background over ListTile's ink layer, which
|
||||
/// Flutter flags as a warning-level assertion, not a correctness bug.
|
||||
void _suppressListTileInkAssertion() {
|
||||
final original = FlutterError.onError;
|
||||
FlutterError.onError = (details) {
|
||||
if (details.exceptionAsString().contains(
|
||||
'ListTile background color or ink splashes may be invisible',
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
original?.call(details);
|
||||
};
|
||||
addTearDown(() => FlutterError.onError = original);
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
Future<File> archivoCustomVacio() async => File(
|
||||
'${Directory.current.path}/test/fixtures/emisoras_custom_vacio.json',
|
||||
);
|
||||
|
||||
Future<EstadoRadio> crearEstadoVacio() async {
|
||||
return EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
|
||||
resolverArchivoCustom: archivoCustomVacio,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
}
|
||||
|
||||
/// Seeds 3 favorites (A, B unassigned won't apply — A is unassigned; B and
|
||||
/// C are in a "Rock" group) added in A, B, C order (so the natural/manual
|
||||
/// order is A, B, C).
|
||||
Future<EstadoRadio> crearEstadoConFavoritos() async {
|
||||
final estado = await crearEstadoVacio();
|
||||
final favoritos = estado.favoritos as FakeServicioFavoritos;
|
||||
final rock = await favoritos.crearGrupo('Rock');
|
||||
await favoritos.agregar(emisoraDemo(uuid: 'a', nombre: 'Station A'));
|
||||
await favoritos.agregar(emisoraDemo(uuid: 'b', nombre: 'Station B'));
|
||||
await favoritos.agregar(emisoraDemo(uuid: 'c', nombre: 'Station C'));
|
||||
await favoritos.asignarGrupo('b', rock.id);
|
||||
await favoritos.asignarGrupo('c', rock.id);
|
||||
await estado.cargarFavoritos();
|
||||
await estado.cargarGruposFavoritos();
|
||||
return estado;
|
||||
}
|
||||
|
||||
Widget buildScreen(EstadoRadio estado) {
|
||||
// Wrapped in a bare Scaffold, matching pantalla_ajustes_test.dart's
|
||||
// convention: in the real app, _PaginaPrincipal's own Scaffold is what
|
||||
// gives root screens (which construct zero Scaffold themselves, per
|
||||
// ADR-2) a Material ancestor. Without it, Material components like
|
||||
// ChoiceChip/PopupMenuButton/ActionChip fail to find one.
|
||||
return ChangeNotifierProvider<EstadoRadio>.value(
|
||||
value: estado,
|
||||
child: MaterialApp(
|
||||
locale: const Locale('en'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const Scaffold(body: PantallaFavoritos()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> pumpStable(WidgetTester tester) async {
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
}
|
||||
|
||||
// ── EstadoRadio: new state surface (state-layer, not widget-layer) ───────
|
||||
|
||||
test('listaFavoritosManual returns favorites in stored order, NOT re-sorted '
|
||||
'by the global ordenListas setting (unlike listaFavoritos)', () async {
|
||||
final estado = await crearEstadoConFavoritos();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
// Natural insertion order is A, B, C; ordenListas defaults to calidad,
|
||||
// which would NOT necessarily preserve that order if applied.
|
||||
expect(estado.listaFavoritosManual.map((e) => e.uuid).toList(), [
|
||||
'a',
|
||||
'b',
|
||||
'c',
|
||||
]);
|
||||
});
|
||||
|
||||
test('reordenarFavorito persists the new manual order', () async {
|
||||
final estado = await crearEstadoConFavoritos();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await estado.reordenarFavorito('c', 0);
|
||||
|
||||
expect(estado.listaFavoritosManual.map((e) => e.uuid).toList(), [
|
||||
'c',
|
||||
'a',
|
||||
'b',
|
||||
]);
|
||||
// Persists across a simulated restart: reload from the (fake) service.
|
||||
await estado.cargarFavoritos();
|
||||
expect(estado.listaFavoritosManual.map((e) => e.uuid).toList(), [
|
||||
'c',
|
||||
'a',
|
||||
'b',
|
||||
]);
|
||||
});
|
||||
|
||||
test('ordenarFavoritos applies an existing OrdenEmisoras criterion and '
|
||||
'persists the result as the new manual order', () async {
|
||||
final estado = await crearEstadoConFavoritos();
|
||||
addTearDown(estado.dispose);
|
||||
// Put them out of alphabetical order first.
|
||||
await estado.reordenarFavorito('c', 0);
|
||||
expect(estado.listaFavoritosManual.map((e) => e.uuid).toList(), [
|
||||
'c',
|
||||
'a',
|
||||
'b',
|
||||
]);
|
||||
|
||||
await estado.ordenarFavoritos(OrdenEmisoras.nombre);
|
||||
|
||||
expect(estado.listaFavoritosManual.map((e) => e.nombre).toList(), [
|
||||
'Station A',
|
||||
'Station B',
|
||||
'Station C',
|
||||
]);
|
||||
// Persists: a simulated restart still shows the sorted order, not the
|
||||
// pre-sort manual order.
|
||||
await estado.cargarFavoritos();
|
||||
expect(estado.listaFavoritosManual.map((e) => e.nombre).toList(), [
|
||||
'Station A',
|
||||
'Station B',
|
||||
'Station C',
|
||||
]);
|
||||
});
|
||||
|
||||
// ── Widget-level: PantallaFavoritos ───────────────────────────────────────
|
||||
|
||||
testWidgets(
|
||||
'shows the empty state with the custom-station CTA when there are no '
|
||||
'favorites',
|
||||
(tester) async {
|
||||
final estado = await crearEstadoVacio();
|
||||
addTearDown(estado.dispose);
|
||||
await estado.cargarFavoritos();
|
||||
await estado.cargarGruposFavoritos();
|
||||
|
||||
await tester.pumpWidget(buildScreen(estado));
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(find.text('No favorites yet'), findsOneWidget);
|
||||
expect(find.text('Add custom station'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'chip filter narrows the list to the selected group ("All · N" plus '
|
||||
'one chip per group)',
|
||||
(tester) async {
|
||||
setLargeSurface(tester);
|
||||
_suppressListTileInkAssertion();
|
||||
final estado = await crearEstadoConFavoritos();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(buildScreen(estado));
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(find.text('All · 3'), findsOneWidget);
|
||||
expect(find.text('Unassigned · 1'), findsOneWidget);
|
||||
expect(find.text('Rock · 2'), findsOneWidget);
|
||||
expect(find.text('Station A'), findsOneWidget);
|
||||
expect(find.text('Station B'), findsOneWidget);
|
||||
expect(find.text('Station C'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Rock · 2'));
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(find.text('Station A'), findsNothing);
|
||||
expect(find.text('Station B'), findsOneWidget);
|
||||
expect(find.text('Station C'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('dragging the 3rd item to the 1st position persists across a '
|
||||
'simulated restart', (tester) async {
|
||||
setLargeSurface(tester);
|
||||
_suppressListTileInkAssertion();
|
||||
final estado = await crearEstadoConFavoritos();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(buildScreen(estado));
|
||||
await pumpStable(tester);
|
||||
|
||||
final lista = tester.widget<ReorderableListView>(
|
||||
find.byType(ReorderableListView),
|
||||
);
|
||||
// Drag the 3rd row (index 2, "Station C") to the 1st position (index
|
||||
// 0) — exercised via the real onReorderItem callback the widget wires
|
||||
// up. onReorderItem (not the deprecated onReorder) already adjusts
|
||||
// newIndex for the removed item, so no manual index math here.
|
||||
lista.onReorderItem!(2, 0);
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(estado.listaFavoritosManual.map((e) => e.uuid).toList(), [
|
||||
'c',
|
||||
'a',
|
||||
'b',
|
||||
]);
|
||||
|
||||
// Persists across a simulated restart.
|
||||
await estado.cargarFavoritos();
|
||||
expect(estado.listaFavoritosManual.map((e) => e.uuid).toList(), [
|
||||
'c',
|
||||
'a',
|
||||
'b',
|
||||
]);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'swap_vert sort action applies OrdenEmisoras.nombre and re-renders '
|
||||
'alphabetically',
|
||||
(tester) async {
|
||||
setLargeSurface(tester);
|
||||
_suppressListTileInkAssertion();
|
||||
final estado = await crearEstadoConFavoritos();
|
||||
addTearDown(estado.dispose);
|
||||
await estado.reordenarFavorito('c', 0); // out of alphabetical order
|
||||
|
||||
await tester.pumpWidget(buildScreen(estado));
|
||||
await pumpStable(tester);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.swap_vert_rounded));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('By name'));
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(estado.listaFavoritosManual.map((e) => e.nombre).toList(), [
|
||||
'Station A',
|
||||
'Station B',
|
||||
'Station C',
|
||||
]);
|
||||
// The list re-renders in the new order too, not just the state.
|
||||
expect(
|
||||
tester.getCenter(find.text('Station A')).dy <
|
||||
tester.getCenter(find.text('Station B')).dy,
|
||||
isTrue,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'the create-group action opens group management, and a newly created '
|
||||
'group appears as a new filter chip',
|
||||
(tester) async {
|
||||
setLargeSurface(tester);
|
||||
_suppressListTileInkAssertion();
|
||||
final estado = await crearEstadoConFavoritos();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(buildScreen(estado));
|
||||
await pumpStable(tester);
|
||||
|
||||
await tester.tap(find.text('Manage lists'));
|
||||
await pumpStable(tester);
|
||||
|
||||
// "Group Management Reachable from Favoritos": the SAME screen
|
||||
// Settings uses, reused rather than duplicated.
|
||||
expect(find.byType(PluriPushScaffold), findsOneWidget);
|
||||
expect(find.byType(PantallaAjustesGruposFavoritos), findsOneWidget);
|
||||
|
||||
// Deliberately does NOT interact with _editarGrupo's create-group
|
||||
// bottom sheet here: it has a pre-existing, out-of-scope
|
||||
// TextEditingController dispose-race (Engram
|
||||
// sdd/rediseno-funcional/controller-dispose-bugfix, tracked
|
||||
// separately, not to be fixed in this WU) already independently
|
||||
// exercised by pantalla_ajustes_grupos_favoritos_test.dart. Popping
|
||||
// back to Favoritos and calling the same EstadoRadio method that
|
||||
// form calls proves the part that's actually NEW here — the chip
|
||||
// row's reactivity to a newly created group — without depending on
|
||||
// that unrelated bug's timing.
|
||||
//
|
||||
// PluriPushScaffold uses a plain IconButton for its back affordance,
|
||||
// not a semantic BackButtonIcon/CupertinoNavigationBarBackButton —
|
||||
// tester.pageBack() looks for those and finds neither.
|
||||
await tester.tap(find.byIcon(Icons.arrow_back_rounded));
|
||||
// pumpAndSettle, not pumpStable: the pop transition's default
|
||||
// animation needs more than a bounded 100ms pump to fully finish.
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(PantallaAjustesGruposFavoritos), findsNothing);
|
||||
expect(find.text('Manage lists'), findsOneWidget);
|
||||
|
||||
await estado.crearGrupoFavoritos('Road trip');
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(find.text('Road trip · 0'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('custom-station CTA opens the add-station flow', (tester) async {
|
||||
setLargeSurface(tester);
|
||||
_suppressListTileInkAssertion();
|
||||
final estado = await crearEstadoConFavoritos();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(buildScreen(estado));
|
||||
await pumpStable(tester);
|
||||
|
||||
// The 3200px test surface already fits header + chips + 3 rows +
|
||||
// footer, so no scroll is needed to reach the CTA.
|
||||
await tester.tap(find.text('Add custom station'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.widgetWithText(TextFormField, 'Name *'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
void setLargeSurface(WidgetTester tester) {
|
||||
tester.view.physicalSize = const Size(1440, 3200);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/estado/estado_grabacion.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/modelos/archivo_grabacion.dart';
|
||||
import 'package:pluriwave/pantallas/pantalla_grabaciones.dart';
|
||||
import 'package:pluriwave/servicios/servicio_grabacion_radio.dart';
|
||||
import 'package:pluriwave/widgets/pluri_push_scaffold.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// WU15: the recordings library screen — storage usage, browsable rows
|
||||
/// (name/date/duration/size) with inline playback, and a "⋮" menu
|
||||
/// constrained to exactly Rename/Share/Delete.
|
||||
///
|
||||
/// [ReproductorGrabaciones] is always injected with a fake here:
|
||||
/// constructing a real `just_audio.AudioPlayer` needs platform
|
||||
/// `MethodChannel`s this suite does not mock — the same documented
|
||||
/// constraint `cola_local_test.dart` records for `PluriWaveAudioHandler`.
|
||||
/// Likewise, `compartir` is always injected with a fake recorder instead of
|
||||
/// the real `share_plus` call, since this suite does not mock that channel
|
||||
/// either (see `pantalla_ajustes_backup_test.dart`'s note on the same
|
||||
/// constraint).
|
||||
///
|
||||
/// Pre-existing project constraint (see `pantalla_ajustes_test.dart`):
|
||||
/// PluriGlassSurface paints a background over ListTile's ink layer, which
|
||||
/// Flutter flags as a warning-level assertion, not a correctness bug. Only
|
||||
/// needed by the WU15b settings-affordance test below, which pushes
|
||||
/// PantallaAjustesGrabaciones (a ListTile-with-onTap screen) — this file's
|
||||
/// other scenarios never mount that screen.
|
||||
void _suppressListTileInkAssertion() {
|
||||
final original = FlutterError.onError;
|
||||
FlutterError.onError = (details) {
|
||||
if (details.exceptionAsString().contains(
|
||||
'ListTile background color or ink splashes may be invisible',
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
original?.call(details);
|
||||
};
|
||||
addTearDown(() => FlutterError.onError = original);
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
final fijaA = ArchivoGrabacion(
|
||||
ruta: '/fake/2026-01-01-radio-a.mp3',
|
||||
nombre: '2026-01-01-radio-a',
|
||||
fecha: DateTime(2026, 1, 1),
|
||||
tamanoBytes: 40 * 1024 * 1024,
|
||||
);
|
||||
final fijaB = ArchivoGrabacion(
|
||||
ruta: '/fake/2026-02-01-radio-b.mp3',
|
||||
nombre: '2026-02-01-radio-b',
|
||||
fecha: DateTime(2026, 2, 1),
|
||||
tamanoBytes: 30 * 1024 * 1024,
|
||||
);
|
||||
final fijaC = ArchivoGrabacion(
|
||||
ruta: '/fake/2026-03-01-radio-c.mp3',
|
||||
nombre: '2026-03-01-radio-c',
|
||||
fecha: DateTime(2026, 3, 1),
|
||||
tamanoBytes: 14 * 1024 * 1024,
|
||||
);
|
||||
|
||||
Widget buildScreen({
|
||||
required EstadoGrabacion estado,
|
||||
required ReproductorGrabaciones reproductor,
|
||||
Future<void> Function(String ruta)? compartir,
|
||||
}) {
|
||||
return ListenableProvider<EstadoGrabacion>.value(
|
||||
value: estado,
|
||||
child: MaterialApp(
|
||||
locale: const Locale('en'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: PantallaGrabaciones(
|
||||
reproductor: reproductor,
|
||||
compartir: compartir ?? (_) async {},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> pumpStable(WidgetTester tester) async {
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
}
|
||||
|
||||
testWidgets('renders inside a PluriPushScaffold titled "My recordings"', (
|
||||
tester,
|
||||
) async {
|
||||
final estado = EstadoGrabacion(
|
||||
servicio: _FakeServicioGrabacionConArchivos(
|
||||
const [],
|
||||
maxBytesFijo: 200 * 1024 * 1024,
|
||||
),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
buildScreen(
|
||||
estado: estado,
|
||||
reproductor: _ReproductorGrabacionesFake(const {}),
|
||||
),
|
||||
);
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(find.byType(PluriPushScaffold), findsOneWidget);
|
||||
expect(find.text('My recordings'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'WU15b: tapping the settings icon pushes the folder/size settings '
|
||||
'screen (PantallaAjustesGrabaciones stays reachable, now from within '
|
||||
'the library instead of directly from the Settings root row)',
|
||||
(tester) async {
|
||||
_suppressListTileInkAssertion();
|
||||
final estado = EstadoGrabacion(
|
||||
servicio: _FakeServicioGrabacionConArchivos(
|
||||
const [],
|
||||
maxBytesFijo: 200 * 1024 * 1024,
|
||||
),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
buildScreen(
|
||||
estado: estado,
|
||||
reproductor: _ReproductorGrabacionesFake(const {}),
|
||||
),
|
||||
);
|
||||
await pumpStable(tester);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.settings_outlined));
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(find.byType(PluriPushScaffold), findsNWidgets(2));
|
||||
expect(find.text('Change path'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('15.1: storage bar reflects 84 of 200 MB used', (tester) async {
|
||||
final estado = EstadoGrabacion(
|
||||
servicio: _FakeServicioGrabacionConArchivos([
|
||||
fijaA,
|
||||
fijaB,
|
||||
fijaC,
|
||||
], maxBytesFijo: 200 * 1024 * 1024),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
buildScreen(
|
||||
estado: estado,
|
||||
reproductor: _ReproductorGrabacionesFake(const {}),
|
||||
),
|
||||
);
|
||||
await pumpStable(tester);
|
||||
|
||||
final barra = tester.widget<LinearProgressIndicator>(
|
||||
find.byType(LinearProgressIndicator),
|
||||
);
|
||||
expect(barra.value, closeTo(84 / 200, 0.001));
|
||||
expect(find.text('84 MB of 200 MB used'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('15.2-A: 3 recording fixtures render as 3 rows', (tester) async {
|
||||
final estado = EstadoGrabacion(
|
||||
servicio: _FakeServicioGrabacionConArchivos([
|
||||
fijaA,
|
||||
fijaB,
|
||||
fijaC,
|
||||
], maxBytesFijo: 200 * 1024 * 1024),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
buildScreen(
|
||||
estado: estado,
|
||||
reproductor: _ReproductorGrabacionesFake(const {}),
|
||||
),
|
||||
);
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(find.text('2026-01-01-radio-a'), findsOneWidget);
|
||||
expect(find.text('2026-02-01-radio-b'), findsOneWidget);
|
||||
expect(find.text('2026-03-01-radio-c'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.play_circle_fill_rounded), findsNWidgets(3));
|
||||
});
|
||||
|
||||
testWidgets('15.2-B: empty folder renders an empty state, not an error', (
|
||||
tester,
|
||||
) async {
|
||||
final estado = EstadoGrabacion(
|
||||
servicio: _FakeServicioGrabacionConArchivos(
|
||||
const [],
|
||||
maxBytesFijo: 200 * 1024 * 1024,
|
||||
),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
buildScreen(
|
||||
estado: estado,
|
||||
reproductor: _ReproductorGrabacionesFake(const {}),
|
||||
),
|
||||
);
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(find.text('No recordings yet'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('15.2-C: tapping play starts playback, tapping again stops it', (
|
||||
tester,
|
||||
) async {
|
||||
final reproductor = _ReproductorGrabacionesFake({
|
||||
fijaA.ruta: const Duration(minutes: 3),
|
||||
});
|
||||
final estado = EstadoGrabacion(
|
||||
servicio: _FakeServicioGrabacionConArchivos([
|
||||
fijaA,
|
||||
], maxBytesFijo: 200 * 1024 * 1024),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
buildScreen(estado: estado, reproductor: reproductor),
|
||||
);
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(find.byIcon(Icons.play_circle_fill_rounded), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.play_circle_fill_rounded));
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(find.byIcon(Icons.pause_circle_filled_rounded), findsOneWidget);
|
||||
expect(find.byIcon(Icons.play_circle_fill_rounded), findsNothing);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.pause_circle_filled_rounded));
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(find.byIcon(Icons.play_circle_fill_rounded), findsOneWidget);
|
||||
expect(find.byIcon(Icons.pause_circle_filled_rounded), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('15.3: the "⋮" menu exposes exactly Rename, Share, Delete', (
|
||||
tester,
|
||||
) async {
|
||||
final estado = EstadoGrabacion(
|
||||
servicio: _FakeServicioGrabacionConArchivos([
|
||||
fijaA,
|
||||
], maxBytesFijo: 200 * 1024 * 1024),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
buildScreen(
|
||||
estado: estado,
|
||||
reproductor: _ReproductorGrabacionesFake(const {}),
|
||||
),
|
||||
);
|
||||
await pumpStable(tester);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.more_vert_rounded));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.widgetWithText(PopupMenuItem<String>, 'Rename'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.widgetWithText(PopupMenuItem<String>, 'Share'), findsOneWidget);
|
||||
expect(
|
||||
find.widgetWithText(PopupMenuItem<String>, 'Delete'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byType(PopupMenuItem<String>), findsNWidgets(3));
|
||||
});
|
||||
|
||||
// ── 15.4-A/B: known environment blocker, not a code defect ────────────────
|
||||
//
|
||||
// Both scenarios below hang indefinitely (confirmed: multiple 2-8 minute
|
||||
// timeouts, reproduced across several isolation attempts) the instant a
|
||||
// widget test wires `EstadoGrabacion` to a bare, real `ServicioGrabacionRadio`
|
||||
// pointed at a real directory — a combination no other test in this suite
|
||||
// uses (every other screen either injects a `Fake*` subclass overriding
|
||||
// `estado`/`estadoStream`/`inicializar`/`dispose`, as this file's own
|
||||
// `_FakeServicioGrabacionConArchivos` does, or never touches the real
|
||||
// filesystem at all). Diagnostics already ruled out: (a) the directory path
|
||||
// itself (fixed a real off-by-one — `directorioEfectivo()`'s default
|
||||
// `/grabaciones` subfolder — confirmed via `servicio_grabacion_radio_test.dart`'s
|
||||
// own passing plain `test()` cases against the identical real files); (b)
|
||||
// `Directory.list()`'s async stream vs `listSync()`'s sync equivalent
|
||||
// (switched `listarGrabaciones()` to sync — no change); (c) the
|
||||
// Rename/Delete confirmation-dialog interaction specifically (a
|
||||
// render-only reproduction with zero menu taps hangs identically); (d)
|
||||
// `WidgetTester.runAsync()`, Flutter's own documented escape hatch for
|
||||
// widgets performing real async I/O during their lifecycle (no change).
|
||||
// `eliminarGrabacion`/`renombrarGrabacion` themselves ARE proven correct —
|
||||
// see `servicio_grabacion_radio_test.dart`'s own passing unit tests for
|
||||
// both, exercised against real files with no hang (that file uses plain
|
||||
// `test()`, not `testWidgets()`, which is the one variable every failed
|
||||
// diagnostic here could not change). Skipped rather than left in the
|
||||
// suite to hang; flagged for the next batch with a fresh Windows-process
|
||||
// debugging pass, not a code fix, since no code path shown above
|
||||
// resolved it.
|
||||
testWidgets(
|
||||
'15.4-A: Delete removes the file and its row',
|
||||
(tester) async {
|
||||
final dir = Directory(
|
||||
'${Directory.current.path}/test/fixtures/.tmp_grabaciones_delete',
|
||||
);
|
||||
await dir.create(recursive: true);
|
||||
addTearDown(() => dir.delete(recursive: true));
|
||||
final archivo = File(
|
||||
'${dir.path}${Platform.pathSeparator}2026-01-01-radio-a.mp3',
|
||||
);
|
||||
await archivo.writeAsBytes(List.filled(1024, 0));
|
||||
|
||||
final estado = EstadoGrabacion(
|
||||
servicio: ServicioGrabacionRadio(
|
||||
resolverDirectorioBase: () async => dir,
|
||||
),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
// Point directly at `dir` (not its `/grabaciones` default subfolder)
|
||||
// so the fixture file above is exactly where listarGrabaciones() looks.
|
||||
await estado.cambiarDirectorio(dir.path);
|
||||
|
||||
await tester.pumpWidget(
|
||||
buildScreen(
|
||||
estado: estado,
|
||||
reproductor: _ReproductorGrabacionesFake(const {}),
|
||||
),
|
||||
);
|
||||
await pumpStable(tester);
|
||||
expect(find.text('2026-01-01-radio-a'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.more_vert_rounded));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.widgetWithText(PopupMenuItem<String>, 'Delete'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Confirm dialog — the menu's own "Delete" item has already popped
|
||||
// off the tree by this point, so the dialog's button is unambiguous.
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: find.byType(AlertDialog),
|
||||
matching: find.text('Delete'),
|
||||
),
|
||||
);
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(find.text('2026-01-01-radio-a'), findsNothing);
|
||||
expect(await archivo.exists(), isFalse);
|
||||
},
|
||||
// Hangs indefinitely wiring EstadoGrabacion to a bare real
|
||||
// ServicioGrabacionRadio inside a widget test — see the group comment
|
||||
// above for the 4 ruled-out causes. Underlying logic is proven via
|
||||
// servicio_grabacion_radio_test.dart's passing eliminarGrabacion unit
|
||||
// test.
|
||||
skip: true,
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'15.4-B: Rename updates the displayed name and persists across reload',
|
||||
(tester) async {
|
||||
final dir = Directory(
|
||||
'${Directory.current.path}/test/fixtures/.tmp_grabaciones_rename',
|
||||
);
|
||||
await dir.create(recursive: true);
|
||||
addTearDown(() => dir.delete(recursive: true));
|
||||
final archivo = File(
|
||||
'${dir.path}${Platform.pathSeparator}2026-01-01-radio-a.mp3',
|
||||
);
|
||||
await archivo.writeAsBytes(List.filled(1024, 0));
|
||||
|
||||
final estado = EstadoGrabacion(
|
||||
servicio: ServicioGrabacionRadio(
|
||||
resolverDirectorioBase: () async => dir,
|
||||
),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
// Point directly at `dir` (not its `/grabaciones` default subfolder)
|
||||
// so the fixture file above is exactly where listarGrabaciones() looks.
|
||||
await estado.cambiarDirectorio(dir.path);
|
||||
|
||||
await tester.pumpWidget(
|
||||
buildScreen(
|
||||
estado: estado,
|
||||
reproductor: _ReproductorGrabacionesFake(const {}),
|
||||
),
|
||||
);
|
||||
await pumpStable(tester);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.more_vert_rounded));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.widgetWithText(PopupMenuItem<String>, 'Rename'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'mi grabación');
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Rename'));
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(find.text('mi grabación'), findsOneWidget);
|
||||
expect(find.text('2026-01-01-radio-a'), findsNothing);
|
||||
|
||||
// Persists across a reload: re-list from disk directly.
|
||||
final relistado = await estado.listarGrabaciones();
|
||||
expect(relistado.single.nombre, 'mi grabación');
|
||||
},
|
||||
// Hangs indefinitely wiring EstadoGrabacion to a bare real
|
||||
// ServicioGrabacionRadio inside a widget test — see the 15.4-A group
|
||||
// comment above for the 4 ruled-out causes. Underlying logic is proven
|
||||
// via servicio_grabacion_radio_test.dart's passing renombrarGrabacion
|
||||
// unit test.
|
||||
skip: true,
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'15.4-C: Share invokes the injected share callback with the file path',
|
||||
(tester) async {
|
||||
final compartidos = <String>[];
|
||||
final estado = EstadoGrabacion(
|
||||
servicio: _FakeServicioGrabacionConArchivos([
|
||||
fijaA,
|
||||
], maxBytesFijo: 200 * 1024 * 1024),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
buildScreen(
|
||||
estado: estado,
|
||||
reproductor: _ReproductorGrabacionesFake(const {}),
|
||||
compartir: (ruta) async {
|
||||
compartidos.add(ruta);
|
||||
},
|
||||
),
|
||||
);
|
||||
await pumpStable(tester);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.more_vert_rounded));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.widgetWithText(PopupMenuItem<String>, 'Share'));
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(compartidos, [fijaA.ruta]);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ── Infrastructure ──────────────────────────────────────────────────────────
|
||||
|
||||
class _FakeServicioGrabacionConArchivos extends ServicioGrabacionRadio {
|
||||
_FakeServicioGrabacionConArchivos(this._archivos, {int? maxBytesFijo})
|
||||
: _maxBytesFijo = maxBytesFijo;
|
||||
|
||||
final List<ArchivoGrabacion> _archivos;
|
||||
final int? _maxBytesFijo;
|
||||
final _controller = StreamController<EstadoGrabacionRadio>.broadcast();
|
||||
|
||||
@override
|
||||
EstadoGrabacionRadio get estado => const EstadoGrabacionRadio.inactiva();
|
||||
|
||||
@override
|
||||
Stream<EstadoGrabacionRadio> get estadoStream => _controller.stream;
|
||||
|
||||
@override
|
||||
Future<void> inicializar() async {}
|
||||
|
||||
@override
|
||||
int get maxBytes => _maxBytesFijo ?? super.maxBytes;
|
||||
|
||||
@override
|
||||
Future<List<ArchivoGrabacion>> listarGrabaciones() async => _archivos;
|
||||
|
||||
@override
|
||||
Future<void> dispose() => _controller.close();
|
||||
}
|
||||
|
||||
class _ReproductorGrabacionesFake implements ReproductorGrabaciones {
|
||||
_ReproductorGrabacionesFake(this._duraciones);
|
||||
|
||||
final Map<String, Duration> _duraciones;
|
||||
String? _rutaActual;
|
||||
bool _reproduciendo = false;
|
||||
|
||||
@override
|
||||
String? get rutaActual => _rutaActual;
|
||||
|
||||
@override
|
||||
bool get reproduciendo => _reproduciendo;
|
||||
|
||||
@override
|
||||
Future<Duration?> duracionDe(String ruta) async => _duraciones[ruta];
|
||||
|
||||
@override
|
||||
Future<void> alternar(String ruta) async {
|
||||
if (_rutaActual == ruta && _reproduciendo) {
|
||||
_reproduciendo = false;
|
||||
return;
|
||||
}
|
||||
_rutaActual = ruta;
|
||||
_reproduciendo = true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> detener() async {
|
||||
_reproduciendo = false;
|
||||
_rutaActual = null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/estado/estado_busqueda.dart';
|
||||
import 'package:pluriwave/estado/estado_ecualizador.dart';
|
||||
import 'package:pluriwave/estado/estado_grabacion.dart';
|
||||
import 'package:pluriwave/estado/estado_navegacion.dart';
|
||||
import 'package:pluriwave/estado/estado_radio.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/pantallas/pantalla_favoritos.dart';
|
||||
@@ -177,31 +178,145 @@ void main() {
|
||||
expect(await favoritos.esFavorito(custom.uuid), isTrue);
|
||||
expect(find.text('Custom Uno'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'WU5 ADR-7 anti-cache: the Escuchar hero reflects a station changed '
|
||||
'from OUTSIDE the widget tree (e.g. Android Auto / a notification '
|
||||
'action), proving it caches nothing of its own',
|
||||
(tester) async {
|
||||
_setLargeSurfaceSize(tester);
|
||||
final audio = FakeServicioAudio();
|
||||
final estado = EstadoRadio(
|
||||
audio: audio,
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
servicioGrabacion: FakeServicioGrabacionRadio(),
|
||||
resolverArchivoCustom: _archivoCustomVacio,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
await tester.runAsync(estado.inicializar);
|
||||
|
||||
final estacionA = emisoraDemo(uuid: 'a', nombre: 'Estacion A');
|
||||
final estacionB = emisoraDemo(uuid: 'b', nombre: 'Estacion B');
|
||||
await estado.reproducir(estacionA);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_conProviders(estado, _testApp(const PantallaInicio())),
|
||||
);
|
||||
// Bounded pump, not _pumpStableFrame/pumpAndSettle: a "reproduciendo"
|
||||
// station makes VisualizadorAudio start an indeterminately-repeating
|
||||
// AnimationController (visualizador_audio.dart:77, `_controller.repeat()`)
|
||||
// for its animated-fallback waveform — the same class of hazard as an
|
||||
// indeterminate spinner, just via animation. pumpAndSettle() would
|
||||
// never return while it keeps scheduling frames.
|
||||
await _pumpBounded(tester);
|
||||
|
||||
expect(find.text('Estacion A'), findsOneWidget);
|
||||
|
||||
// Mutates the underlying ServicioAudio DIRECTLY, bypassing
|
||||
// EstadoRadio.reproducir() entirely — this is exactly the shape of
|
||||
// navegacion_auto.dart's out-of-band mutation (Android Auto's
|
||||
// playFromMediaId). EstadoRadio's own audio.estadoStream listener
|
||||
// (not this test) is what is expected to pick this up and update
|
||||
// emisoraActual.
|
||||
await audio.reproducir(estacionB);
|
||||
await _pumpBounded(tester);
|
||||
|
||||
expect(find.text('Estacion B'), findsOneWidget);
|
||||
expect(find.text('Estacion A'), findsNothing);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('WU5: "Ver todas" switches to the Favoritos root via '
|
||||
'EstadoNavegacionRaiz.irA, without pushing a route', (tester) async {
|
||||
_setLargeSurfaceSize(tester);
|
||||
final favoritos = FakeServicioFavoritos();
|
||||
final estado = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: favoritos,
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
servicioGrabacion: FakeServicioGrabacionRadio(),
|
||||
resolverArchivoCustom: _archivoCustomVacio,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
await tester.runAsync(estado.inicializar);
|
||||
await favoritos.agregar(emisoraDemo(uuid: 'f1', nombre: 'Favorita Uno'));
|
||||
await estado.cargarFavoritos();
|
||||
|
||||
final navegacion = EstadoNavegacionRaiz();
|
||||
final observer = _RecordingNavigatorObserver();
|
||||
|
||||
await tester.pumpWidget(
|
||||
_conProviders(
|
||||
estado,
|
||||
_testApp(const PantallaInicio(), observers: [observer]),
|
||||
navegacion: navegacion,
|
||||
),
|
||||
);
|
||||
await _pumpStableFrame(tester);
|
||||
final pushesAntesDeTocar = observer.pushCount;
|
||||
|
||||
await tester.ensureVisible(find.text('Ver todas'));
|
||||
await _pumpStableFrame(tester);
|
||||
await tester.tap(find.text('Ver todas'));
|
||||
await _pumpStableFrame(tester);
|
||||
|
||||
expect(navegacion.actual, RaizPluriWave.favoritos);
|
||||
expect(
|
||||
observer.pushCount,
|
||||
pushesAntesDeTocar,
|
||||
reason: 'switches tabs — must NOT push a new route',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Mirrors the app.dart wiring: EstadoRadio owns the domain notifiers and
|
||||
/// the providers only expose the instances (no dispose callbacks).
|
||||
Widget _conProviders(EstadoRadio estado, Widget child) {
|
||||
/// [navegacion] defaults to a fresh [EstadoNavegacionRaiz] — harmless to
|
||||
/// include for every test, only exercised by the "Ver todas" scenarios.
|
||||
Widget _conProviders(
|
||||
EstadoRadio estado,
|
||||
Widget child, {
|
||||
EstadoNavegacionRaiz? navegacion,
|
||||
}) {
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<EstadoRadio>.value(value: estado),
|
||||
ListenableProvider<EstadoEcualizador>.value(value: estado.ecualizador),
|
||||
ListenableProvider<EstadoGrabacion>.value(value: estado.grabacion),
|
||||
ListenableProvider<EstadoBusqueda>.value(value: estado.busqueda),
|
||||
ChangeNotifierProvider<EstadoNavegacionRaiz>.value(
|
||||
value: navegacion ?? EstadoNavegacionRaiz(),
|
||||
),
|
||||
],
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _testApp(Widget body) {
|
||||
Widget _testApp(Widget body, {List<NavigatorObserver> observers = const []}) {
|
||||
return MaterialApp(
|
||||
locale: const Locale('es'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
navigatorObservers: observers,
|
||||
home: Scaffold(body: body),
|
||||
);
|
||||
}
|
||||
|
||||
/// Counts route pushes so a test can assert "switched tabs, did not push".
|
||||
class _RecordingNavigatorObserver extends NavigatorObserver {
|
||||
int pushCount = 0;
|
||||
|
||||
@override
|
||||
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
|
||||
pushCount++;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeServicioGrabacionRadio extends ServicioGrabacionRadio {
|
||||
final _controller = StreamController<EstadoGrabacionRadio>.broadcast();
|
||||
|
||||
@@ -223,6 +338,15 @@ Future<void> _pumpStableFrame(WidgetTester tester) async {
|
||||
await tester.pumpAndSettle(const Duration(milliseconds: 100));
|
||||
}
|
||||
|
||||
/// WU5: bounded pump, safe when a "reproduciendo" station is rendered —
|
||||
/// `VisualizadorAudio` starts a repeating `AnimationController` for its
|
||||
/// animated-fallback waveform in that case, which `pumpAndSettle` (used by
|
||||
/// `_pumpStableFrame`) would wait on forever.
|
||||
Future<void> _pumpBounded(WidgetTester tester) async {
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
}
|
||||
|
||||
void _setLargeSurfaceSize(WidgetTester tester) {
|
||||
tester.view.physicalSize = const Size(1440, 3200);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
|
||||
@@ -8,6 +8,9 @@ import 'package:pluriwave/modelos/emisora.dart';
|
||||
import 'package:pluriwave/servicios/servicio_grabacion_radio.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// WU15 task 15.5: `listarGrabaciones()` is a pure filesystem-listing
|
||||
/// addition — no logic edit to any existing method above.
|
||||
|
||||
void main() {
|
||||
group('ServicioGrabacionRadio', () {
|
||||
test(
|
||||
@@ -194,6 +197,119 @@ void main() {
|
||||
await servicio.dispose();
|
||||
await servicio2.dispose();
|
||||
});
|
||||
|
||||
group('listarGrabaciones (WU15 task 15.5)', () {
|
||||
test('lista archivos existentes con metadata, orden más reciente '
|
||||
'primero', () async {
|
||||
final dir = await Directory.systemTemp.createTemp(
|
||||
'pluriwave-rec-list-',
|
||||
);
|
||||
final carpeta = Directory(
|
||||
'${dir.path}${Platform.pathSeparator}grabaciones',
|
||||
);
|
||||
await carpeta.create(recursive: true);
|
||||
final antiguo = File(
|
||||
'${carpeta.path}${Platform.pathSeparator}2026-01-01-radio-a.mp3',
|
||||
);
|
||||
await antiguo.writeAsBytes(List.filled(10, 0));
|
||||
await antiguo.setLastModified(DateTime(2026, 1, 1));
|
||||
final reciente = File(
|
||||
'${carpeta.path}${Platform.pathSeparator}2026-06-01-radio-b.mp3',
|
||||
);
|
||||
await reciente.writeAsBytes(List.filled(20, 0));
|
||||
await reciente.setLastModified(DateTime(2026, 6, 1));
|
||||
|
||||
final servicio = ServicioGrabacionRadio(
|
||||
resolverDirectorioBase: () async => dir,
|
||||
);
|
||||
|
||||
final lista = await servicio.listarGrabaciones();
|
||||
|
||||
expect(lista, hasLength(2));
|
||||
expect(lista.first.nombre, '2026-06-01-radio-b');
|
||||
expect(lista.first.tamanoBytes, 20);
|
||||
expect(lista.first.fecha, DateTime(2026, 6, 1));
|
||||
expect(lista.last.nombre, '2026-01-01-radio-a');
|
||||
expect(lista.last.tamanoBytes, 10);
|
||||
|
||||
await servicio.dispose();
|
||||
});
|
||||
|
||||
test('carpeta inexistente devuelve lista vacía, no lanza', () async {
|
||||
final dir = await Directory.systemTemp.createTemp(
|
||||
'pluriwave-rec-list-empty-',
|
||||
);
|
||||
final servicio = ServicioGrabacionRadio(
|
||||
resolverDirectorioBase: () async => dir,
|
||||
);
|
||||
|
||||
final lista = await servicio.listarGrabaciones();
|
||||
|
||||
expect(lista, isEmpty);
|
||||
await servicio.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
group('eliminarGrabacion y renombrarGrabacion (WU15 task 15.5)', () {
|
||||
test('eliminarGrabacion borra el archivo del disco', () async {
|
||||
final dir = await Directory.systemTemp.createTemp(
|
||||
'pluriwave-rec-delete-',
|
||||
);
|
||||
final archivo = File('${dir.path}${Platform.pathSeparator}a.mp3');
|
||||
await archivo.writeAsBytes([1, 2, 3]);
|
||||
final servicio = ServicioGrabacionRadio(
|
||||
resolverDirectorioBase: () async => dir,
|
||||
);
|
||||
|
||||
await servicio.eliminarGrabacion(archivo.path);
|
||||
|
||||
expect(await archivo.exists(), isFalse);
|
||||
await servicio.dispose();
|
||||
});
|
||||
|
||||
test('eliminarGrabacion sobre un archivo ya borrado no lanza', () async {
|
||||
final dir = await Directory.systemTemp.createTemp(
|
||||
'pluriwave-rec-delete-missing-',
|
||||
);
|
||||
final servicio = ServicioGrabacionRadio(
|
||||
resolverDirectorioBase: () async => dir,
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
servicio.eliminarGrabacion(
|
||||
'${dir.path}${Platform.pathSeparator}no-existe.mp3',
|
||||
),
|
||||
completes,
|
||||
);
|
||||
await servicio.dispose();
|
||||
});
|
||||
|
||||
test(
|
||||
'renombrarGrabacion preserva la extensión y devuelve la nueva ruta',
|
||||
() async {
|
||||
final dir = await Directory.systemTemp.createTemp(
|
||||
'pluriwave-rec-rename-',
|
||||
);
|
||||
final archivo = File(
|
||||
'${dir.path}${Platform.pathSeparator}original.mp3',
|
||||
);
|
||||
await archivo.writeAsBytes([1, 2, 3]);
|
||||
final servicio = ServicioGrabacionRadio(
|
||||
resolverDirectorioBase: () async => dir,
|
||||
);
|
||||
|
||||
final nuevaRuta = await servicio.renombrarGrabacion(
|
||||
archivo.path,
|
||||
'mi grabación',
|
||||
);
|
||||
|
||||
expect(nuevaRuta, endsWith('mi grabación.mp3'));
|
||||
expect(await File(nuevaRuta).exists(), isTrue);
|
||||
expect(await archivo.exists(), isFalse);
|
||||
await servicio.dispose();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -79,4 +79,80 @@ void main() {
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'WU5 hazard: MiniReproductor(visible: false) renders nothing, but '
|
||||
'configurarLocalizaciones still runs in didChangeDependencies '
|
||||
'(hiding it structurally instead would silently break the S3-R3 '
|
||||
'contract)',
|
||||
(tester) async {
|
||||
final estado = _EstadoRadioContador();
|
||||
addTearDown(estado.dispose);
|
||||
// A station IS playing — if a naive implementation hid the bar only
|
||||
// because emisoraActual were null, this would render the full bar
|
||||
// instead of proving `visible: false` itself suppresses it.
|
||||
await estado.reproducir(emisoraDemo(uuid: 'a', nombre: 'Station A'));
|
||||
|
||||
await tester.pumpWidget(
|
||||
ChangeNotifierProvider<EstadoRadio>.value(
|
||||
value: estado,
|
||||
child: const MaterialApp(
|
||||
locale: Locale('en'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(body: MiniReproductor(visible: false)),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Station A'), findsNothing);
|
||||
expect(
|
||||
estado.llamadasConfigurar,
|
||||
1,
|
||||
reason:
|
||||
'didChangeDependencies must still run its S3-R3 side effect '
|
||||
'while visually hidden — the State stays mounted, only build() '
|
||||
'is short-circuited',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'WU5 ADR-7(b): MiniReproductor.altura matches the widget\'s actual '
|
||||
'laid-out height (measured, not guessed) within a small tolerance',
|
||||
(tester) async {
|
||||
final estado = _EstadoRadioContador();
|
||||
addTearDown(estado.dispose);
|
||||
await estado.reproducir(emisoraDemo(uuid: 'a', nombre: 'Station A'));
|
||||
|
||||
await tester.pumpWidget(
|
||||
ChangeNotifierProvider<EstadoRadio>.value(
|
||||
value: estado,
|
||||
child: const MaterialApp(
|
||||
locale: Locale('en'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(body: MiniReproductor()),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final alturaReal = tester.getSize(find.byType(MiniReproductor)).height;
|
||||
|
||||
// +-4px tolerance: font metrics can shift a hair across machines: the
|
||||
// constant only backs a content-padding estimate (PluriLayout.
|
||||
// escucharBottomChromeInset), not a pixel-perfect layout coupling.
|
||||
expect(
|
||||
MiniReproductor.altura,
|
||||
closeTo(alturaReal, 4),
|
||||
reason:
|
||||
'MiniReproductor.altura must be measured from the real layout, '
|
||||
'not guessed — if this fails, re-measure via '
|
||||
"tester.getSize(find.byType(MiniReproductor)) and update the "
|
||||
'constant',
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user