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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user