refactor(state): extract recording and search state, scope screen rebuilds

- New EstadoGrabacion owns the recording service, subscription, directory/size preferences and open-file actions
- New EstadoBusqueda owns search, nearby stations, pagination and the min-bitrate filter
- New orden_emisoras.dart with the OrdenEmisoras enum, shared sorter and list identity memoization so context.select comparisons work on derived lists
- Large screens (inicio, buscar, favoritos, ajustes, reproductor) consume scoped selects/dedicated notifiers instead of root context.watch<EstadoRadio>, so audio buffer events no longer rebuild whole screens
- Remove all 15 TODO(S4b) compat members from EstadoRadio; consumers use the dedicated providers. EstadoRadio drops from ~1121 to 753 lines, keeping playback/stations/favorites orchestration
- 8 new tests including a rebuild-scoping probe (110 total green), flutter analyze clean
This commit is contained in:
2026-06-11 21:43:18 +02:00
parent 0416b301b2
commit 52855e75c2
17 changed files with 1195 additions and 643 deletions
+50 -32
View File
@@ -9,6 +9,7 @@ import 'package:share_plus/share_plus.dart' show Share, XFile;
import 'package:uuid/uuid.dart';
import '../estado/estado_ecualizador.dart';
import '../estado/estado_grabacion.dart';
import '../estado/estado_idioma.dart';
import '../estado/estado_radio.dart';
import '../l10n/display_names.dart';
@@ -85,7 +86,7 @@ class _SeccionGrabaciones extends StatelessWidget {
const _SeccionGrabaciones();
Future<void> _seleccionarRuta(BuildContext context) async {
final estado = context.read<EstadoRadio>();
final estado = context.read<EstadoGrabacion>();
final messenger = ScaffoldMessenger.of(context);
final l10n = AppLocalizations.of(context);
final ruta = await FilePicker.platform.getDirectoryPath(
@@ -93,7 +94,7 @@ class _SeccionGrabaciones extends StatelessWidget {
);
if (ruta == null) return;
try {
await estado.cambiarDirectorioGrabacion(ruta);
await estado.cambiarDirectorio(ruta);
if (!context.mounted) return;
messenger.showSnackBar(
SnackBar(content: Text(l10n.recordingsPathUpdated)),
@@ -107,10 +108,10 @@ class _SeccionGrabaciones extends StatelessWidget {
}
Future<void> _restaurarRuta(BuildContext context) async {
final estado = context.read<EstadoRadio>();
final estado = context.read<EstadoGrabacion>();
final messenger = ScaffoldMessenger.of(context);
final l10n = AppLocalizations.of(context);
await estado.restaurarDirectorioGrabacion();
await estado.restaurarDirectorio();
if (!context.mounted) return;
messenger.showSnackBar(
SnackBar(content: Text(l10n.recordingsDefaultFolderRestored)),
@@ -118,11 +119,11 @@ class _SeccionGrabaciones extends StatelessWidget {
}
Future<void> _abrirCarpeta(BuildContext context) async {
final estado = context.read<EstadoRadio>();
final estado = context.read<EstadoGrabacion>();
final messenger = ScaffoldMessenger.of(context);
final l10n = AppLocalizations.of(context);
try {
final abierto = await estado.abrirDirectorioGrabacion();
final abierto = await estado.abrirDirectorio();
if (!context.mounted) return;
if (!abierto) {
messenger.showSnackBar(
@@ -138,9 +139,9 @@ class _SeccionGrabaciones extends StatelessWidget {
}
Future<void> _editarTamanoMaximo(BuildContext context) async {
final estado = context.read<EstadoRadio>();
final estado = context.read<EstadoGrabacion>();
final l10n = AppLocalizations.of(context);
final actualMb = _bytesAMegabytes(estado.maxBytesGrabacion);
final actualMb = _bytesAMegabytes(estado.maxBytes);
final controller = TextEditingController(text: actualMb.toString());
final nuevoMb = await showModalBottomSheet<int>(
@@ -186,7 +187,7 @@ class _SeccionGrabaciones extends StatelessWidget {
);
controller.dispose();
if (nuevoMb == null || !context.mounted) return;
await estado.cambiarMaxBytesGrabacion(nuevoMb * 1024 * 1024);
await estado.cambiarMaxBytes(nuevoMb * 1024 * 1024);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.recordingsMaxSizeSaved(nuevoMb))),
@@ -198,7 +199,9 @@ class _SeccionGrabaciones extends StatelessWidget {
@override
Widget build(BuildContext context) {
final estado = context.watch<EstadoRadio>();
// 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(
@@ -216,7 +219,7 @@ class _SeccionGrabaciones extends StatelessWidget {
],
),
FutureBuilder<String>(
future: estado.directorioGrabacionEfectivo(),
future: estado.directorioEfectivo(),
builder:
(ctx, snap) => ListTile(
contentPadding: EdgeInsets.zero,
@@ -256,9 +259,7 @@ class _SeccionGrabaciones extends StatelessWidget {
leading: const Icon(Icons.sd_storage_rounded),
title: Text(l10n.recordingsMaxSizeTitle),
subtitle: Text(
l10n.recordingsMaxSizeSubtitle(
_bytesAMegabytes(estado.maxBytesGrabacion),
),
l10n.recordingsMaxSizeSubtitle(_bytesAMegabytes(estado.maxBytes)),
),
onTap: () => _editarTamanoMaximo(context),
),
@@ -301,8 +302,10 @@ class _SeccionTimerSueno extends StatelessWidget {
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final estado = context.watch<EstadoRadio>();
final presets = estado.timerSuenoPresetsSegundos;
// S4-R5: scoped select — rebuilds only when the presets list changes.
final presets = context.select<EstadoRadio, List<int>>(
(e) => e.timerSuenoPresetsSegundos,
);
return PluriGlassSurface(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -654,7 +657,10 @@ class _SeccionOrdenListas extends StatelessWidget {
@override
Widget build(BuildContext context) {
final estado = context.watch<EstadoRadio>();
// S4-R5: scoped select — rebuilds only when the ordering changes.
final orden = context.select<EstadoRadio, OrdenEmisoras>(
(e) => e.ordenListas,
);
final l10n = AppLocalizations.of(context);
return PluriGlassSurface(
child: Column(
@@ -684,9 +690,9 @@ class _SeccionOrdenListas extends StatelessWidget {
label: Text(l10n.stationOrderByQuality),
),
],
selected: {estado.ordenListas},
selected: {orden},
onSelectionChanged: (value) {
estado.cambiarOrdenListas(value.first);
context.read<EstadoRadio>().cambiarOrdenListas(value.first);
},
),
const SizedBox(height: 8),
@@ -790,9 +796,11 @@ class _SeccionGruposFavoritos extends StatelessWidget {
@override
Widget build(BuildContext context) {
final estado = context.watch<EstadoRadio>();
final l10n = AppLocalizations.of(context);
final grupos = estado.gruposFavoritos;
// S4-R5: scoped select — rebuilds only when the groups list changes.
final grupos = context.select<EstadoRadio, List<GrupoFavoritos>>(
(e) => e.gruposFavoritos,
);
return PluriGlassSurface(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -858,11 +866,18 @@ class _SeccionEmisoraPreferida extends StatelessWidget {
@override
Widget build(BuildContext context) {
final estado = context.watch<EstadoRadio>();
final l10n = AppLocalizations.of(context);
final favoritas = estado.listaFavoritos;
final preferida = estado.emisoraPreferida;
final opciones = _opciones(estado, preferida);
// S4-R5: scoped selects over identity-memoized getters.
final favoritas = context.select<EstadoRadio, List<Emisora>>(
(e) => e.listaFavoritos,
);
final disponibles = context.select<EstadoRadio, List<Emisora>>(
(e) => e.emisorasDisponiblesPreferencia,
);
final preferida = context.select<EstadoRadio, Emisora?>(
(e) => e.emisoraPreferida,
);
final opciones = _opciones(favoritas, disponibles, preferida);
return PluriGlassSurface(
child: Column(
@@ -947,11 +962,12 @@ class _SeccionEmisoraPreferida extends StatelessWidget {
);
}
List<Emisora> _opciones(EstadoRadio estado, Emisora? preferida) {
final base =
estado.listaFavoritos.isNotEmpty
? estado.listaFavoritos
: estado.emisorasDisponiblesPreferencia;
List<Emisora> _opciones(
List<Emisora> favoritas,
List<Emisora> disponibles,
Emisora? preferida,
) {
final base = favoritas.isNotEmpty ? favoritas : disponibles;
final mapa = <String, Emisora>{
for (final emisora in base) emisora.uuid: emisora,
};
@@ -967,8 +983,10 @@ class _SeccionEmisoras extends StatelessWidget {
@override
Widget build(BuildContext context) {
final estado = context.watch<EstadoRadio>();
final custom = estado.emisorasCustom;
// S4-R5: scoped select — rebuilds only when the custom list changes.
final custom = context.select<EstadoRadio, List<Emisora>>(
(e) => e.emisorasCustom,
);
return PluriGlassSurface(
child: Column(
+39 -18
View File
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:provider/provider.dart';
import '../estado/estado_radio.dart';
import '../estado/estado_busqueda.dart';
import '../l10n/gen/app_localizations.dart';
import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_icon.dart';
@@ -58,7 +58,7 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
void _buscar() {
final q = _controller.text.trim();
context.read<EstadoRadio>().buscar(
context.read<EstadoBusqueda>().buscar(
nombre: q.isNotEmpty ? q : null,
pais: _paisSeleccionado,
idioma: _idiomaSeleccionado,
@@ -68,7 +68,9 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
@override
Widget build(BuildContext context) {
final estado = context.watch<EstadoRadio>();
// S4-R3/S4-R5: this screen depends only on search state, so it watches
// the dedicated notifier — playback events no longer rebuild it.
final estado = context.watch<EstadoBusqueda>();
final theme = Theme.of(context);
final l10n = AppLocalizations.of(context);
@@ -85,7 +87,12 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
),
),
Padding(
padding: const EdgeInsets.fromLTRB(PluriLayout.horizontal, 10, PluriLayout.horizontal, 0),
padding: const EdgeInsets.fromLTRB(
PluriLayout.horizontal,
10,
PluriLayout.horizontal,
0,
),
child: PluriGlassSurface(
padding: const EdgeInsets.all(10),
borderRadius: BorderRadius.circular(999),
@@ -132,7 +139,13 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
),
_seccionFiltroInt(
l10n.searchMinQualityFilterLabel,
const [('64 kbps', 64), ('96 kbps', 96), ('128 kbps', 128), ('192 kbps', 192), ('320 kbps', 320)],
const [
('64 kbps', 64),
('96 kbps', 96),
('128 kbps', 128),
('192 kbps', 192),
('320 kbps', 320),
],
_calidadMinima,
(v) {
setState(() => _calidadMinima = v);
@@ -144,7 +157,6 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
);
}
Widget _seccionFiltro(
String titulo,
List<(String, String)> opciones,
@@ -153,7 +165,12 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.fromLTRB(PluriLayout.horizontal, 8, PluriLayout.horizontal, 0),
padding: const EdgeInsets.fromLTRB(
PluriLayout.horizontal,
8,
PluriLayout.horizontal,
0,
),
child: PluriGlassSurface(
padding: const EdgeInsets.all(10),
child: Column(
@@ -198,7 +215,12 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.fromLTRB(PluriLayout.horizontal, 8, PluriLayout.horizontal, 0),
padding: const EdgeInsets.fromLTRB(
PluriLayout.horizontal,
8,
PluriLayout.horizontal,
0,
),
child: PluriGlassSurface(
padding: const EdgeInsets.all(10),
child: Column(
@@ -235,16 +257,16 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
);
}
Widget _resultados(EstadoRadio estado, ThemeData theme) {
Widget _resultados(EstadoBusqueda estado, ThemeData theme) {
final l10n = AppLocalizations.of(context);
if (estado.cargandoBusqueda) {
if (estado.cargando) {
return const SizedBox(
height: 220,
child: Center(child: CircularProgressIndicator()),
);
}
final resultados = estado.resultadosBusqueda;
final resultados = estado.resultados;
if (resultados.isEmpty) {
final sinFiltros =
@@ -255,8 +277,7 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
height: 260,
child: PluriEmptyState(
glyph: PluriIconGlyph.search,
title:
sinFiltros ? l10n.searchEmptyTitle : l10n.searchNoResultsTitle,
title: sinFiltros ? l10n.searchEmptyTitle : l10n.searchNoResultsTitle,
subtitle:
sinFiltros
? l10n.searchEmptySubtitle
@@ -265,7 +286,7 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
);
}
final total = resultados.length + (estado.hayMasBusqueda ? 1 : 0);
final total = resultados.length + (estado.hayMas ? 1 : 0);
return ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
@@ -274,16 +295,16 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
separatorBuilder: (_, __) => const SizedBox(height: 10),
itemBuilder: (context, i) {
if (i >= resultados.length) {
if (!estado.cargandoMasBusqueda) {
Future<void>.microtask(estado.cargarMasBusqueda);
if (!estado.cargandoMas) {
Future<void>.microtask(estado.cargarMas);
}
return const Padding(
padding: EdgeInsets.all(18),
child: Center(child: CircularProgressIndicator()),
);
}
if (i >= resultados.length - 5 && estado.hayMasBusqueda) {
Future<void>.microtask(estado.cargarMasBusqueda);
if (i >= resultados.length - 5 && estado.hayMas) {
Future<void>.microtask(estado.cargarMas);
}
return TarjetaEmisora(
emisora: resultados[i],
+51 -40
View File
@@ -19,9 +19,15 @@ class PantallaFavoritos extends StatelessWidget {
@override
Widget build(BuildContext context) {
final estado = context.watch<EstadoRadio>();
final favoritos = estado.listaFavoritos;
final grupos = estado.gruposFavoritos;
// 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,
);
final grupos = context.select<EstadoRadio, List<GrupoFavoritos>>(
(e) => e.gruposFavoritos,
);
final l10n = AppLocalizations.of(context);
if (favoritos.isEmpty) {
@@ -49,16 +55,17 @@ class PantallaFavoritos extends StatelessWidget {
);
}
final gruposVisibles = grupos.isEmpty
? [
GrupoFavoritos(
id: GrupoFavoritos.sinAsignarId,
nombre: l10n.favoriteGroupsUnassigned,
orden: 0,
protegido: true,
),
]
: grupos;
final gruposVisibles =
grupos.isEmpty
? [
GrupoFavoritos(
id: GrupoFavoritos.sinAsignarId,
nombre: l10n.favoriteGroupsUnassigned,
orden: 0,
protegido: true,
),
]
: grupos;
return CustomScrollView(
slivers: [
@@ -86,9 +93,10 @@ class PantallaFavoritos extends StatelessWidget {
_GrupoFavoritosPanel(
grupo: grupo,
grupos: gruposVisibles,
emisoras: favoritos
.where((e) => e.grupoFavoritosId == grupo.id)
.toList(),
emisoras:
favoritos
.where((e) => e.grupoFavoritosId == grupo.id)
.toList(),
),
const SizedBox(height: 12),
],
@@ -125,7 +133,9 @@ class _GrupoFavoritosPanel extends StatelessWidget {
children: [
Row(
children: [
Icon(grupo.esSinAsignar ? Icons.lock_rounded : Icons.folder_rounded),
Icon(
grupo.esSinAsignar ? Icons.lock_rounded : Icons.folder_rounded,
),
const SizedBox(width: 8),
Expanded(
child: Text(
@@ -181,30 +191,31 @@ class _FavoritoItem extends StatelessWidget {
final seleccionado = await showModalBottomSheet<String>(
context: context,
showDragHandle: true,
builder: (ctx) => SafeArea(
child: ListView(
shrinkWrap: true,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 12),
child: Text(
l10n.favoriteGroupsAssign,
style: Theme.of(ctx).textTheme.titleLarge,
),
),
for (final grupo in grupos)
ListTile(
leading: Icon(
grupo.id == emisora.grupoFavoritosId
? Icons.radio_button_checked_rounded
: Icons.radio_button_off_rounded,
builder:
(ctx) => SafeArea(
child: ListView(
shrinkWrap: true,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 12),
child: Text(
l10n.favoriteGroupsAssign,
style: Theme.of(ctx).textTheme.titleLarge,
),
),
title: Text(_nombreVisible(l10n, grupo)),
onTap: () => Navigator.pop(ctx, grupo.id),
),
],
),
),
for (final grupo in grupos)
ListTile(
leading: Icon(
grupo.id == emisora.grupoFavoritosId
? Icons.radio_button_checked_rounded
: Icons.radio_button_off_rounded,
),
title: Text(_nombreVisible(l10n, grupo)),
onTap: () => Navigator.pop(ctx, grupo.id),
),
],
),
),
);
if (seleccionado == null || !context.mounted) return;
await context.read<EstadoRadio>().asignarGrupoFavorito(
+57 -39
View File
@@ -3,8 +3,10 @@ import 'package:flutter_animate/flutter_animate.dart';
import 'package:provider/provider.dart';
import 'package:shimmer/shimmer.dart' as shimmer;
import '../estado/estado_busqueda.dart';
import '../estado/estado_radio.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/emisora.dart';
import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_icon.dart';
import '../widgets/pluri_layout.dart';
@@ -40,20 +42,25 @@ class _PantallaInicioState extends State<PantallaInicio> {
@override
Widget build(BuildContext context) {
final estado = context.watch<EstadoRadio>();
// S4-R5: no root watch on EstadoRadio. Every field is consumed through
// context.select over identity-memoized getters, so audio buffer events
// (which notify EstadoRadio) no longer rebuild this screen.
final theme = Theme.of(context);
final l10n = AppLocalizations.of(context);
final error = context.select<EstadoRadio, String?>((e) => e.error);
return RefreshIndicator(
onRefresh: estado.cargarPopulares,
onRefresh: () => context.read<EstadoRadio>().cargarPopulares(),
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(child: _heroHeader(context, estado, l10n)),
SliverToBoxAdapter(child: _seccionCercanas(estado, theme, l10n)),
SliverToBoxAdapter(child: _seccionTendencias(estado, theme, l10n)),
SliverToBoxAdapter(child: _heroHeader(context, l10n)),
SliverToBoxAdapter(child: _seccionCercanas(context, theme, l10n)),
SliverToBoxAdapter(child: _seccionTendencias(context, theme, l10n)),
SliverToBoxAdapter(child: _chipGeneros(context, theme, l10n)),
if (estado.error != null)
SliverToBoxAdapter(child: _errorBanner(estado, theme, l10n)),
if (error != null)
SliverToBoxAdapter(
child: _errorBanner(context, error, theme, l10n),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(
PluriLayout.horizontal,
@@ -61,30 +68,29 @@ class _PantallaInicioState extends State<PantallaInicio> {
PluriLayout.horizontal,
PluriLayout.bottomChromeInset,
),
sliver: _gridEmisoras(estado, l10n),
sliver: _gridEmisoras(context, l10n),
),
],
),
);
}
Widget _heroHeader(
BuildContext context,
EstadoRadio estado,
AppLocalizations l10n,
) {
Widget _heroHeader(BuildContext context, AppLocalizations l10n) {
final totalEmisoras = context.select<EstadoRadio, int>(
(e) => e.emisorasInicio.length,
);
return PluriScreenHeader(
title: l10n.appTitle,
subtitle: l10n.homeScreenSubtitle,
glyph: PluriIconGlyph.home,
primaryActionLabel: l10n.exploreStations,
onPrimaryAction: estado.cargarPopulares,
onPrimaryAction: () => context.read<EstadoRadio>().cargarPopulares(),
trailing: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
PluriStatusPill(
icon: Icons.public_rounded,
label: l10n.stationsCount(estado.emisorasInicio.length),
label: l10n.stationsCount(totalEmisoras),
accent: Theme.of(context).colorScheme.secondary,
),
const SizedBox(height: 8),
@@ -95,11 +101,13 @@ class _PantallaInicioState extends State<PantallaInicio> {
}
Widget _seccionCercanas(
EstadoRadio estado,
BuildContext context,
ThemeData theme,
AppLocalizations l10n,
) {
final pais = estado.paisCercanoDetectado;
// Nearby stations live in EstadoBusqueda (S4-R3).
final busqueda = context.watch<EstadoBusqueda>();
final pais = busqueda.paisCercanoDetectado;
return Padding(
padding: const EdgeInsets.fromLTRB(
PluriLayout.horizontal,
@@ -124,11 +132,11 @@ class _PantallaInicioState extends State<PantallaInicio> {
),
TextButton.icon(
onPressed:
estado.cargandoCercanas
busqueda.cargandoCercanas
? null
: estado.cargarEmisorasCercanas,
: busqueda.cargarEmisorasCercanas,
icon:
estado.cargandoCercanas
busqueda.cargandoCercanas
? const SizedBox(
width: 16,
height: 16,
@@ -139,23 +147,23 @@ class _PantallaInicioState extends State<PantallaInicio> {
),
],
),
if (estado.errorCercanas != null)
if (busqueda.errorCercanas != null)
Text(
estado.errorCercanas!,
busqueda.errorCercanas!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
),
if (estado.emisorasCercanas.isNotEmpty) ...[
if (busqueda.cercanas.isNotEmpty) ...[
const SizedBox(height: 8),
SizedBox(
height: 76,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: estado.emisorasCercanas.length,
itemCount: busqueda.cercanas.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (context, i) {
final emisora = estado.emisorasCercanas[i];
final emisora = busqueda.cercanas[i];
return SizedBox(
width: 260,
child: TarjetaEmisora(
@@ -175,10 +183,16 @@ class _PantallaInicioState extends State<PantallaInicio> {
}
Widget _seccionTendencias(
EstadoRadio estado,
BuildContext context,
ThemeData theme,
AppLocalizations l10n,
) {
final cargando = context.select<EstadoRadio, bool>(
(e) => e.cargandoPopulares,
);
final tendencias = context.select<EstadoRadio, List<Emisora>>(
(e) => e.tendencias,
);
return Padding(
padding: const EdgeInsets.fromLTRB(
PluriLayout.horizontal,
@@ -196,7 +210,7 @@ class _PantallaInicioState extends State<PantallaInicio> {
SizedBox(
height: 56,
child:
estado.cargandoPopulares
cargando
? ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: 5,
@@ -205,10 +219,10 @@ class _PantallaInicioState extends State<PantallaInicio> {
)
: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: estado.tendencias.length,
itemCount: tendencias.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (context, i) {
final e = estado.tendencias[i];
final e = tendencias[i];
return ActionChip(
avatar: const Icon(
Icons.graphic_eq_rounded,
@@ -259,7 +273,7 @@ class _PantallaInicioState extends State<PantallaInicio> {
_generoSeleccionado = seleccionado ? null : g;
});
if (!seleccionado) {
context.read<EstadoRadio>().buscar(tag: g);
context.read<EstadoBusqueda>().buscar(tag: g);
} else {
context.read<EstadoRadio>().cargarPopulares();
}
@@ -274,7 +288,8 @@ class _PantallaInicioState extends State<PantallaInicio> {
}
Widget _errorBanner(
EstadoRadio estado,
BuildContext context,
String error,
ThemeData theme,
AppLocalizations l10n,
) {
@@ -286,9 +301,9 @@ class _PantallaInicioState extends State<PantallaInicio> {
children: [
Icon(Icons.wifi_off, color: theme.colorScheme.error),
const SizedBox(width: 8),
Expanded(child: Text(estado.error!)),
Expanded(child: Text(error)),
TextButton(
onPressed: estado.cargarPopulares,
onPressed: () => context.read<EstadoRadio>().cargarPopulares(),
child: Text(l10n.retryAction),
),
],
@@ -297,14 +312,17 @@ class _PantallaInicioState extends State<PantallaInicio> {
);
}
Widget _gridEmisoras(EstadoRadio estado, AppLocalizations l10n) {
Widget _gridEmisoras(BuildContext context, AppLocalizations l10n) {
final porGenero = _generoSeleccionado != null;
final emisoras =
_generoSeleccionado != null
? estado.resultadosBusqueda
: estado.emisorasInicio;
porGenero
? context.select<EstadoBusqueda, List<Emisora>>((b) => b.resultados)
: context.select<EstadoRadio, List<Emisora>>(
(e) => e.emisorasInicio,
);
final cargando =
estado.cargandoPopulares ||
(_generoSeleccionado != null && estado.cargandoBusqueda);
context.select<EstadoRadio, bool>((e) => e.cargandoPopulares) ||
(porGenero && context.select<EstadoBusqueda, bool>((b) => b.cargando));
if (cargando) {
return SliverGrid(
+17 -11
View File
@@ -5,6 +5,7 @@ import 'package:provider/provider.dart';
import 'package:shimmer/shimmer.dart';
import '../estado/estado_ecualizador.dart';
import '../estado/estado_grabacion.dart';
import '../estado/estado_radio.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/emisora.dart';
@@ -177,7 +178,7 @@ class _PantallaReproductorState extends State<PantallaReproductor>
emisora: emisoraActiva,
).animate().fadeIn(delay: 300.ms).slideY(begin: 0.3),
const SizedBox(height: 14),
_GrabacionWidget(estado: estado).animate().fadeIn(delay: 360.ms),
const _GrabacionWidget().animate().fadeIn(delay: 360.ms),
const SizedBox(height: 14),
_TimerWidget(estado: estado).animate().fadeIn(delay: 400.ms),
const SizedBox(height: 16),
@@ -358,16 +359,18 @@ class _InfoChips extends StatelessWidget {
}
class _GrabacionWidget extends StatelessWidget {
final EstadoRadio estado;
const _GrabacionWidget({required this.estado});
// Recording state lives in EstadoGrabacion (S4-R2); EstadoRadio no longer
// notifies on recording progress, so this widget watches the new notifier.
const _GrabacionWidget();
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final grabacion = estado.estadoGrabacion;
final estado = context.watch<EstadoGrabacion>();
final grabacion = estado.estado;
final activa = grabacion.activa;
final hayUltimaGrabacion = estado.ultimaGrabacion != null;
final hayUltimaGrabacion = estado.ultimoArchivo != null;
return PluriGlassSurface(
borderRadius: BorderRadius.circular(24),
@@ -416,7 +419,7 @@ class _GrabacionWidget extends StatelessWidget {
label: Text(activa ? l10n.stopAction : l10n.recordAction),
onPressed:
activa
? estado.detenerGrabacion
? estado.detener
: () => _mostrarDialogoGrabacion(context),
),
if (!activa)
@@ -440,7 +443,8 @@ class _GrabacionWidget extends StatelessWidget {
Future<void> _abrirUltimaGrabacion(BuildContext context) async {
final messenger = ScaffoldMessenger.of(context);
final abierto = await estado.abrirUltimaGrabacion();
final abierto =
await context.read<EstadoGrabacion>().abrirUltimaGrabacion();
if (!context.mounted) return;
if (!abierto) {
messenger.showSnackBar(
@@ -453,7 +457,7 @@ class _GrabacionWidget extends StatelessWidget {
Future<void> _abrirCarpetaGrabaciones(BuildContext context) async {
final messenger = ScaffoldMessenger.of(context);
final abierto = await estado.abrirDirectorioGrabacion();
final abierto = await context.read<EstadoGrabacion>().abrirDirectorio();
if (!context.mounted) return;
if (!abierto) {
messenger.showSnackBar(
@@ -467,6 +471,7 @@ class _GrabacionWidget extends StatelessWidget {
}
void _mostrarDialogoGrabacion(BuildContext context) {
final grabacion = context.read<EstadoGrabacion>();
showModalBottomSheet(
context: context,
builder:
@@ -495,7 +500,7 @@ class _GrabacionWidget extends StatelessWidget {
),
label: Text(AppLocalizations.of(ctx).indefiniteOption),
onPressed: () {
estado.iniciarGrabacion();
grabacion.iniciar();
Navigator.pop(ctx);
},
),
@@ -511,7 +516,7 @@ class _GrabacionWidget extends StatelessWidget {
),
),
onPressed: () {
estado.iniciarGrabacion(duracion: opcion.duracion);
grabacion.iniciar(duracion: opcion.duracion);
Navigator.pop(ctx);
},
),
@@ -533,6 +538,7 @@ class _GrabacionWidget extends StatelessWidget {
}
Future<void> _mostrarDuracionPersonalizada(BuildContext context) async {
final grabacion = context.read<EstadoGrabacion>();
final minutosCtrl = TextEditingController();
final segundosCtrl = TextEditingController(text: '0');
final formKey = GlobalKey<FormState>();
@@ -585,7 +591,7 @@ class _GrabacionWidget extends StatelessWidget {
seconds: segundos,
);
if (duracion <= Duration.zero) return;
estado.iniciarGrabacion(duracion: duracion);
grabacion.iniciar(duracion: duracion);
Navigator.pop(ctx);
},
child: Text(AppLocalizations.of(ctx).recordAction),