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(