5 Commits
Author SHA1 Message Date
FreeTLab 3a803ce2bf feat(escuchar): replace discovery browser with embedded player and favorites grid
Restructures PantallaInicio's top of screen: a new _EscucharHero
(square art, live/offline pill, VisualizadorAudio at barras: 30 /
altura: 26 / color: liveGreen, a 5-action transport row - favorite,
EQ toggle, stop, play/pause, sleep - plus a tool-tray entry chip
opening the full player) replaces the old PluriScreenHeader hero, and
a new "Tus emisoras" section (favorites preview, capped, "Ver todas")
follows it. Per design ADR-7, EstadoRadio stays the single source of
truth: the hero is a StatelessWidget with no cached fields, reading
emisoraActual via context.select (uuid-based equality scopes rebuilds
to real station changes) and the fast-changing playback status via
StreamBuilder, the same pattern _Controles/MiniReproductor already
use. The still-present discovery sections (_seccionCercanas onward,
including the old grid) 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.

MiniReproductor gains a `visible` parameter (default true) and a
measured `static const double altura`. app.dart passes
`visible: indice != RaizPluriWave.escuchar.index`, hiding it visually
only (SizedBox.shrink()) while Escuchar is active, since the hero
already shows the same station - the State stays mounted so its
didChangeDependencies side effect (configurarLocalizaciones, S3-R3)
keeps running regardless of tab. altura was measured empirically
(72.0, via tester.getSize) rather than guessed, backing a new derived
PluriLayout.escucharBottomChromeInset constant now wired into
PantallaInicio's own bottom padding.

"Ver todas" switches roots via EstadoNavegacionRaiz.irA(favoritos),
verified via a NavigatorObserver asserting the push count is
unchanged (switches tabs, does not push).

Fixed a pre-existing test-infrastructure gap while writing the
anti-cache test: no test in this codebase had ever exercised
ServicioAudio.androidAudioSessionIdStream against a bare
FakeServicioAudio (pantalla_reproductor.dart has always read it but
has no test file at all) - the real getter needs registrarHandler()
(main.dart, production only) and threw otherwise. Added an empty
stream override to FakeServicioAudio, matching VisualizadorAudio's
own documented no-native-session fallback.

Tests: 614 -> 618 (2 skipped, unchanged). flutter analyze unchanged
at 1 pre-existing info. git diff empty for visualizador_audio.dart
and estado_radio.dart - this WU touches neither.
2026-07-29 00:12:05 +02:00
FreeTLab 504a13641f feat(favoritos): replace stacked group panels with chip-filtered reorderable list
Replaces the stacked per-group panel layout with a single
chip-filtered flat list. Chips read "{name} · {count}" (new ARB keys
favoriteGroupsChipLabel/favoritesFilterAllLabel), one per group plus
an "All" chip. Rows drag-reorder via a leading handle
(ReorderableDragStartListener, buildDefaultDragHandles: false) using
the modern onReorderItem callback rather than the now-@Deprecated
onReorder (Flutter 3.44 marks it obsolete).

EstadoRadio additions: listaFavoritosManual (a new memoized getter
returning the stored order untouched by the global ordenListas
setting - listaFavoritos itself always re-sorts by
name/quality on every read, which would silently discard any
drag-to-reorder), reordenarFavorito (thin wrapper over the
already-existing ServicioFavoritos.reordenar, previously unused
outside its own service test), and ordenarFavoritos (applies an
existing OrdenEmisoras criterion via ordenarEmisoras() and persists
the result as the new manual order, so the swap_vert sort action's
result also survives a restart). listaFavoritos itself is untouched,
so Android Auto's tree and the future Escuchar grid (WU5) are
unaffected by Favoritos' own manual order.

Group management: an "Manage lists" action chip pushes the existing
PantallaAjustesGruposFavoritos screen (Settings' own screen, reused
rather than duplicated) - a second entry point to the same screen.
Custom-station CTA: a new dashed-bordered card opens the add-station
form directly; that form was renamed from private _FormularioEmisora
to public FormularioEmisoraPersonalizada in
pantalla_ajustes_emisoras_personalizadas.dart so both screens share
one implementation. New ARB keys: favoriteGroupsManage,
customStationsAddCta.

Tests: pantalla_favoritos_plural_test.dart (the file tasks.md named)
never imported PantallaFavoritos - it only covers stationCount's ARB
plural formatting, unrelated to this screen. Left it untouched and
added test/pantallas/pantalla_favoritos_test.dart instead: 3
state-layer tests for the new EstadoRadio surface plus 6 widget
scenarios (empty-state CTA, chip filter, drag-reorder persistence,
sort action, group management + chip reactivity, custom-station
CTA). 604 -> 614 tests (2 skipped, unchanged). flutter analyze
unchanged at 1 pre-existing info.

Recorded in tasks.md with the test-file correction and the
design decisions this WU had to make on its own (no ADR covers
Favoritos' manual-order persistence).
2026-07-28 23:49:06 +02:00
FreeTLab ebdde7df01 fix(grabaciones): wire the recordings library into Settings navigation
WU15 shipped PantallaGrabaciones (the recordings library: storage bar,
recording rows, the "..." Rename/Share/Delete menu) fully tested but
reachable from nowhere in the app - a gap flagged in WU15's own
apply-progress notes, not fixed there since it needed a design
decision rather than a guess.

Coordinator ruling applied: the approved mockup's "Ajustes > Grabaciones"
screen depicts the library, not the folder/size settings form. So the
GRABACIONES Y MUSICA group's "Grabaciones" row in pantalla_ajustes.dart
now opens PantallaGrabaciones instead of PantallaAjustesGrabaciones.
The settings form is not dropped - it stays reachable, 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.

One new ARB key (en/es only, per precedent): recordingsLibrarySettingsTooltip.

Tests: 604 -> 605 (one scenario re-targeted in pantalla_ajustes_test.dart,
one new scenario in pantalla_grabaciones_test.dart, which needed the
same ListTile-ink-assertion suppression helper WU3a/WU3b established
since it now pushes a ListTile-bearing settings screen). flutter
analyze unchanged at 1 pre-existing info.

Recorded in tasks.md as WU15b - not part of the original 18-unit plan,
added here to close the gap WU15 flagged.
2026-07-28 23:21:34 +02:00
FreeTLab 589fc54580 feat(grabaciones): add recordings library screen 2026-07-28 23:01:33 +02:00
FreeTLab c1903623be refactor(ajustes): split remaining Settings sections into pushed screens 2026-07-28 22:14:03 +02:00
46 changed files with 5577 additions and 933 deletions
+5 -1
View File
@@ -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,
+20
View File
@@ -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);
+36
View File
@@ -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();
+43
View File
@@ -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": {
+43
View File
@@ -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": {
+150
View File
@@ -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:
+82
View File
@@ -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 => 'بالقرب منك';
+82
View File
@@ -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 => 'আপনার কাছাকাছি';
+82
View File
@@ -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';
+81
View File
@@ -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';
+82
View File
@@ -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';
+82
View File
@@ -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 denregistrement 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';
+82
View File
@@ -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 => 'आपके पास';
+82
View File
@@ -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';
+82
View File
@@ -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';
+82
View File
@@ -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 => '近く';
+82
View File
@@ -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ê';
+82
View File
@@ -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 => 'Рядом с вами';
+82
View File
@@ -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 => '你附近';
+26
View File
@@ -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),
),
);
},
),
],
),
);
}
}
+70 -655
View File
@@ -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,
),
),
],
),
),
);
}
}
+329 -108
View File
@@ -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;
}
+449
View File
@@ -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),
),
],
);
}
}
+472 -22
View File
@@ -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);
+16 -2
View File
@@ -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);
+7
View File
@@ -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,
+276 -76
View File
@@ -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
+10
View File
@@ -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);
});
}
+102 -61
View File
@@ -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);
});
});
}
+354
View File
@@ -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 {}
}
+126 -2
View File
@@ -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',
);
},
);
}