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
+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],