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
+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(