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
+12 -3
View File
@@ -2,7 +2,9 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'estado/estado_busqueda.dart';
import 'estado/estado_ecualizador.dart'; import 'estado/estado_ecualizador.dart';
import 'estado/estado_grabacion.dart';
import 'estado/estado_radio.dart'; import 'estado/estado_radio.dart';
import 'estado/estado_alarmas.dart'; import 'estado/estado_alarmas.dart';
import 'estado/estado_idioma.dart'; import 'estado/estado_idioma.dart';
@@ -36,12 +38,19 @@ class PluriWaveApp extends StatelessWidget {
return MultiProvider( return MultiProvider(
providers: [ providers: [
ChangeNotifierProvider(create: (_) => EstadoRadio(prefs: prefs)), ChangeNotifierProvider(create: (_) => EstadoRadio(prefs: prefs)),
// EQ notifier (S4-R1). Created and disposed by EstadoRadio during // Domain notifiers (S4-R1/R2/R3). Created and disposed by EstadoRadio
// the S4 transition; this provider only exposes the instance, so it // (they need its services and callbacks at construction); these
// declares no dispose callback. // providers only expose the instances, so they declare no dispose
// callback.
ListenableProvider<EstadoEcualizador>( ListenableProvider<EstadoEcualizador>(
create: (context) => context.read<EstadoRadio>().ecualizador, create: (context) => context.read<EstadoRadio>().ecualizador,
), ),
ListenableProvider<EstadoGrabacion>(
create: (context) => context.read<EstadoRadio>().grabacion,
),
ListenableProvider<EstadoBusqueda>(
create: (context) => context.read<EstadoRadio>().busqueda,
),
ChangeNotifierProvider(create: (_) => EstadoAlarmas(prefs: prefs)), ChangeNotifierProvider(create: (_) => EstadoAlarmas(prefs: prefs)),
ChangeNotifierProvider( ChangeNotifierProvider(
create: (_) => EstadoIdioma(sharedPreferences: prefs), create: (_) => EstadoIdioma(sharedPreferences: prefs),
+222
View File
@@ -0,0 +1,222 @@
import 'dart:ui' show Locale, PlatformDispatcher;
import 'package:flutter/foundation.dart';
import 'package:geocoding/geocoding.dart';
import 'package:geolocator/geolocator.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/emisora.dart';
import '../servicios/servicio_radio.dart';
import 'orden_emisoras.dart';
/// Search state extracted from `EstadoRadio` (S4-R3).
///
/// Owns the search query/filters, paged results, the nearby-stations lookup
/// and every loading flag. Notifies ONLY its own listeners so search activity
/// never rebuilds `EstadoRadio` consumers (S4-R5).
class EstadoBusqueda extends ChangeNotifier {
EstadoBusqueda({
required this.radio,
OrdenEmisoras Function()? ordenListas,
AppLocalizations Function()? textos,
void Function(String mensaje)? alError,
}) : _ordenListas = ordenListas ?? (() => OrdenEmisoras.calidad),
_textos = textos ?? (() => lookupAppLocalizations(const Locale('es'))),
_alError = alError;
static const int _tamanoPagina = 30;
static const int _maxResultadosEnMemoria = 180;
final ServicioRadio radio;
/// Current list ordering, owned by EstadoRadio (user preference).
final OrdenEmisoras Function() _ordenListas;
final AppLocalizations Function() _textos;
/// User-visible error sink (EstadoRadio routes it to its snackbar stream).
final void Function(String mensaje)? _alError;
List<Emisora> _resultados = [];
List<Emisora> _cercanas = [];
bool _cargando = false;
bool _cargandoMas = false;
bool _hayMas = true;
bool _cargandoCercanas = false;
String? _paisCercanoDetectado;
String? _errorCercanas;
int _offset = 0;
String? _ultimoNombre;
String? _ultimoPais;
String? _ultimoIdioma;
String? _ultimoTag;
int? _ultimoMinBitrate;
final _memoResultados = MemoLista<Emisora>();
final _memoCercanas = MemoLista<Emisora>();
List<Emisora> get resultados => _memoResultados.obtener([
_resultados,
_ordenListas(),
], () => ordenarEmisoras(_resultados, _ordenListas()));
List<Emisora> get cercanas => _memoCercanas.obtener([
_cercanas,
_ordenListas(),
], () => ordenarEmisoras(_cercanas, _ordenListas()));
bool get cargando => _cargando;
bool get cargandoMas => _cargandoMas;
bool get hayMas => _hayMas;
bool get cargandoCercanas => _cargandoCercanas;
String? get paisCercanoDetectado => _paisCercanoDetectado;
String? get errorCercanas => _errorCercanas;
/// Re-renders sorted views after the user changes the list ordering
/// (called by EstadoRadio, which owns that preference).
void notificarCambioOrden() => notifyListeners();
Future<void> buscar({
String? nombre,
String? pais,
String? idioma,
String? tag,
int? minBitrate,
}) async {
_ultimoNombre = nombre;
_ultimoPais = pais;
_ultimoIdioma = idioma;
_ultimoTag = tag;
_ultimoMinBitrate = minBitrate;
_offset = 0;
_hayMas = true;
_cargando = true;
_resultados = [];
notifyListeners();
try {
final pagina = await _buscarPaginaFiltrada(
nombre: nombre,
pais: pais,
idioma: idioma,
tag: tag,
minBitrate: minBitrate,
);
_resultados = pagina;
} catch (_) {
_alError?.call(_textos().radioSearchError);
} finally {
_cargando = false;
notifyListeners();
}
}
Future<void> cargarMas() async {
if (_cargando || _cargandoMas || !_hayMas) return;
_cargandoMas = true;
notifyListeners();
try {
final pagina = await _buscarPaginaFiltrada(
nombre: _ultimoNombre,
pais: _ultimoPais,
idioma: _ultimoIdioma,
tag: _ultimoTag,
minBitrate: _ultimoMinBitrate,
);
final porUuid = <String, Emisora>{
for (final emisora in _resultados) emisora.uuid: emisora,
};
for (final emisora in pagina) {
porUuid[emisora.uuid] = emisora;
}
var nuevaLista = porUuid.values.toList();
if (nuevaLista.length > _maxResultadosEnMemoria) {
nuevaLista = nuevaLista.sublist(
nuevaLista.length - _maxResultadosEnMemoria,
);
}
_resultados = nuevaLista;
// _buscarPaginaFiltrada actualiza offset/hayMas usando páginas crudas.
_hayMas = _hayMas && pagina.isNotEmpty;
} catch (_) {
_alError?.call(_textos().radioLoadMoreStationsError);
} finally {
_cargandoMas = false;
notifyListeners();
}
}
Future<List<Emisora>> _buscarPaginaFiltrada({
String? nombre,
String? pais,
String? idioma,
String? tag,
int? minBitrate,
}) async {
final acumuladas = <Emisora>[];
var intentos = 0;
while (intentos < 4 && acumuladas.isEmpty && _hayMas) {
final pagina = await radio.buscar(
nombre: nombre,
pais: pais,
idioma: idioma,
tag: tag,
limit: _tamanoPagina,
offset: _offset,
);
_offset += pagina.length;
_hayMas = pagina.length == _tamanoPagina;
acumuladas.addAll(_filtrarMinBitrate(pagina, minBitrate));
intentos++;
}
return acumuladas;
}
List<Emisora> _filtrarMinBitrate(List<Emisora> emisoras, int? minBitrate) {
if (minBitrate == null || minBitrate <= 0) return emisoras;
return emisoras.where((e) => (e.bitrate ?? 0) >= minBitrate).toList();
}
Future<void> cargarEmisorasCercanas() async {
_cargandoCercanas = true;
_errorCercanas = null;
notifyListeners();
try {
var pais = PlatformDispatcher.instance.locale.countryCode;
final servicioActivo = await Geolocator.isLocationServiceEnabled();
if (servicioActivo) {
var permiso = await Geolocator.checkPermission();
if (permiso == LocationPermission.denied) {
permiso = await Geolocator.requestPermission();
}
if (permiso == LocationPermission.always ||
permiso == LocationPermission.whileInUse) {
final posicion = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.low,
timeLimit: Duration(seconds: 8),
),
);
final marcas = await placemarkFromCoordinates(
posicion.latitude,
posicion.longitude,
);
if (marcas.isNotEmpty) {
pais = marcas.first.isoCountryCode ?? pais;
}
}
}
if (pais == null || pais.isEmpty) {
throw StateError('nearby-region-not-detected');
}
_paisCercanoDetectado = pais;
_cercanas = _filtrarMinBitrate(
await radio.buscar(pais: pais, limit: 30),
_ultimoMinBitrate,
);
} catch (_) {
_errorCercanas = _textos().radioNearbyStationsError;
_cercanas = [];
} finally {
_cargandoCercanas = false;
notifyListeners();
}
}
}
+144
View File
@@ -0,0 +1,144 @@
import 'dart:async';
import 'dart:io';
import 'dart:ui' show Locale;
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:url_launcher/url_launcher.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/emisora.dart';
import '../servicios/servicio_grabacion_radio.dart';
/// Recording state extracted from `EstadoRadio` (S4-R2).
///
/// Owns [ServicioGrabacionRadio] and the recording-state subscription, and
/// notifies ONLY its own listeners — recording progress must not rebuild
/// `EstadoRadio` consumers (S4-R5). Playback orchestration (stop recording on
/// pause/stop/station switch) stays in `EstadoRadio`, which keeps a reference
/// to this notifier.
class EstadoGrabacion extends ChangeNotifier {
EstadoGrabacion({
ServicioGrabacionRadio? servicio,
Emisora? Function()? emisoraActual,
void Function(String mensaje)? alError,
}) : servicio = servicio ?? ServicioGrabacionRadio(),
_emisoraActual = emisoraActual ?? (() => null),
_alError = alError {
_suscripcion = this.servicio.estadoStream.listen((estado) {
if (estado.tipo == EstadoGrabacionRadioTipo.error &&
estado.error != null) {
_alError?.call(_textos.radioRecordingError(estado.error!));
}
notifyListeners();
});
}
static const MethodChannel _fileActionsChannel = MethodChannel(
'pluriwave/file_actions',
);
final ServicioGrabacionRadio servicio;
/// Callback into the owner (EstadoRadio) for the currently playing station;
/// keeps this notifier free of any station-list coupling.
final Emisora? Function() _emisoraActual;
/// User-visible error sink (EstadoRadio routes it to its snackbar stream).
final void Function(String mensaje)? _alError;
StreamSubscription<EstadoGrabacionRadio>? _suscripcion;
AppLocalizations? _l10n;
AppLocalizations get _textos {
final actual = _l10n;
if (actual != null) return actual;
return lookupAppLocalizations(const Locale('es'));
}
void configurarLocalizaciones(AppLocalizations l10n) {
_l10n = l10n;
servicio.configurarLocalizaciones(l10n);
}
Future<void> inicializar() => servicio.inicializar();
EstadoGrabacionRadio get estado => servicio.estado;
bool get activa => servicio.estado.activa;
String? get directorioConfigurado => servicio.directorioConfigurado;
int get maxBytes => servicio.maxBytes;
File? get ultimoArchivo => servicio.ultimoArchivo;
Future<void> iniciar({Duration? duracion}) async {
final actual = _emisoraActual();
if (actual == null) {
_alError?.call(_textos.recordingSelectStationFirst);
return;
}
try {
await servicio.iniciar(actual, duracion: duracion);
} catch (e) {
_alError?.call(_textos.recordingStartError(e.toString()));
}
}
Future<void> detener() => servicio.detener();
Future<void> cambiarMaxBytes(int bytes) async {
await servicio.guardarMaxBytes(bytes);
notifyListeners();
}
Future<void> cambiarDirectorio(String path) async {
await servicio.guardarDirectorio(path);
notifyListeners();
}
Future<void> restaurarDirectorio() async {
await servicio.limpiarDirectorioConfigurado();
notifyListeners();
}
Future<String> directorioEfectivo() => servicio.directorioEfectivo();
Future<bool> abrirDirectorio() async {
final ruta = await directorioEfectivo();
await Directory(ruta).create(recursive: true);
if (!kIsWeb && Platform.isAndroid) {
final abierto = await _fileActionsChannel.invokeMethod<bool>(
'viewDirectory',
{'path': ruta},
);
return abierto ?? false;
}
final uri = Uri.directory(ruta);
return launchUrl(uri, mode: LaunchMode.externalApplication);
}
Future<bool> abrirUltimaGrabacion() async {
final archivo = ultimoArchivo;
if (archivo == null || !await archivo.exists()) {
debugPrint('[PluriWave][recordings] last recording missing');
return false;
}
debugPrint('[PluriWave][recordings] opening last file: ${archivo.path}');
if (!kIsWeb && Platform.isAndroid) {
final abierto = await _fileActionsChannel.invokeMethod<bool>('openFile', {
'path': archivo.path,
'mimeType': 'audio/*',
});
return abierto ?? false;
}
return launchUrl(
Uri.file(archivo.path),
mode: LaunchMode.externalApplication,
);
}
@override
void dispose() {
_suscripcion?.cancel();
unawaited(servicio.dispose());
super.dispose();
}
}
+116 -413
View File
@@ -3,20 +3,19 @@ import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart' show Locale; import 'package:flutter/widgets.dart' show Locale;
import 'package:geocoding/geocoding.dart';
import 'package:geolocator/geolocator.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:url_launcher/url_launcher.dart';
import '../l10n/display_names.dart'; import '../l10n/display_names.dart';
import '../l10n/gen/app_localizations.dart'; import '../l10n/gen/app_localizations.dart';
import '../modelos/emisora.dart'; import '../modelos/emisora.dart';
import '../modelos/grupo_favoritos.dart'; import '../modelos/grupo_favoritos.dart';
import '../modelos/preset_ecualizador.dart'; import '../modelos/preset_ecualizador.dart';
import 'estado_busqueda.dart';
import 'estado_ecualizador.dart'; import 'estado_ecualizador.dart';
import 'estado_grabacion.dart';
import 'orden_emisoras.dart';
import '../servicios/servicio_audio.dart'; import '../servicios/servicio_audio.dart';
import '../servicios/servicio_ecualizador.dart'; import '../servicios/servicio_ecualizador.dart';
import '../servicios/servicio_export_import.dart'; import '../servicios/servicio_export_import.dart';
@@ -25,14 +24,15 @@ import '../servicios/servicio_grabacion_radio.dart';
import '../servicios/servicio_radio.dart'; import '../servicios/servicio_radio.dart';
import '../servicios/servicio_timer.dart'; import '../servicios/servicio_timer.dart';
enum OrdenEmisoras { nombre, calidad } export 'orden_emisoras.dart' show OrdenEmisoras;
/// Estado global de la app con ChangeNotifier (Provider). /// Estado global de la app con ChangeNotifier (Provider).
///
/// S4 end-state: playback + stations + favorites orchestration. EQ, recording
/// and search state live in their own notifiers (EstadoEcualizador,
/// EstadoGrabacion, EstadoBusqueda) created here during the S4 transition and
/// exposed app-wide through ListenableProviders in app.dart.
class EstadoRadio extends ChangeNotifier { class EstadoRadio extends ChangeNotifier {
static const MethodChannel _fileActionsChannel = MethodChannel(
'pluriwave/file_actions',
);
EstadoRadio({ EstadoRadio({
ServicioAudio? audio, ServicioAudio? audio,
ServicioFavoritos? favoritos, ServicioFavoritos? favoritos,
@@ -47,7 +47,6 @@ class EstadoRadio extends ChangeNotifier {
radio = radio ?? ServicioRadio(), radio = radio ?? ServicioRadio(),
servicioEcualizador = servicioEcualizador =
servicioEcualizador ?? ServicioEcualizador(prefs: prefs), servicioEcualizador ?? ServicioEcualizador(prefs: prefs),
grabacion = servicioGrabacion ?? ServicioGrabacionRadio(prefs: prefs),
_prefs = prefs, _prefs = prefs,
_resolverArchivoCustom = resolverArchivoCustom { _resolverArchivoCustom = resolverArchivoCustom {
ecualizador = EstadoEcualizador( ecualizador = EstadoEcualizador(
@@ -55,9 +54,19 @@ class EstadoRadio extends ChangeNotifier {
servicio: this.servicioEcualizador, servicio: this.servicioEcualizador,
emisoraActualUuid: () => emisoraActual?.uuid, emisoraActualUuid: () => emisoraActual?.uuid,
); );
grabacion = EstadoGrabacion(
servicio: servicioGrabacion ?? ServicioGrabacionRadio(prefs: prefs),
emisoraActual: () => emisoraActual,
alError: _errorController.add,
);
busqueda = EstadoBusqueda(
radio: this.radio,
ordenListas: () => _ordenListas,
textos: () => _textos,
alError: _errorController.add,
);
timer = ServicioTimer(this.audio); timer = ServicioTimer(this.audio);
_escucharErroresReproduccion(); _escucharErroresReproduccion();
_escucharGrabacion();
if (iniciarAutomaticamente) { if (iniciarAutomaticamente) {
_initFuture = _init(); _initFuture = _init();
} }
@@ -68,11 +77,12 @@ class EstadoRadio extends ChangeNotifier {
final ServicioRadio radio; final ServicioRadio radio;
final ServicioEcualizador servicioEcualizador; final ServicioEcualizador servicioEcualizador;
/// EQ state extracted to its own notifier (S4-R1). Owned (and disposed) /// Domain notifiers extracted from this class (S4). Created and disposed
/// by EstadoRadio during the S4 transition; exposed app-wide through a /// here (they need EstadoRadio's services and callbacks at construction);
/// ListenableProvider in app.dart. /// exposed app-wide through ListenableProviders in app.dart.
late final EstadoEcualizador ecualizador; late final EstadoEcualizador ecualizador;
final ServicioGrabacionRadio grabacion; late final EstadoGrabacion grabacion;
late final EstadoBusqueda busqueda;
static const ServicioExportImport _exportImport = ServicioExportImport(); static const ServicioExportImport _exportImport = ServicioExportImport();
final SharedPreferences? _prefs; final SharedPreferences? _prefs;
final Future<File> Function()? _resolverArchivoCustom; final Future<File> Function()? _resolverArchivoCustom;
@@ -99,7 +109,6 @@ class EstadoRadio extends ChangeNotifier {
late final ServicioTimer timer; late final ServicioTimer timer;
StreamSubscription<EstadoReproduccion>? _suscripcionEstadoAudio; StreamSubscription<EstadoReproduccion>? _suscripcionEstadoAudio;
StreamSubscription<EstadoGrabacionRadio>? _suscripcionGrabacion;
Future<void>? _initFuture; Future<void>? _initFuture;
int _revisionReproduccion = 0; int _revisionReproduccion = 0;
Emisora? _emisoraSeleccionada; Emisora? _emisoraSeleccionada;
@@ -112,26 +121,23 @@ class EstadoRadio extends ChangeNotifier {
List<Emisora> _populares = []; List<Emisora> _populares = [];
List<Emisora> _tendencias = []; List<Emisora> _tendencias = [];
List<Emisora> _resultadosBusqueda = [];
List<Emisora> _emisorasCercanas = [];
List<Emisora> _listaFavoritos = []; List<Emisora> _listaFavoritos = [];
List<GrupoFavoritos> _gruposFavoritos = []; List<GrupoFavoritos> _gruposFavoritos = [];
List<Emisora> _emisorasCustom = []; List<Emisora> _emisorasCustom = [];
bool _cargandoPopulares = false; bool _cargandoPopulares = false;
bool _cargandoBusqueda = false;
bool _cargandoMasBusqueda = false;
bool _hayMasBusqueda = true;
bool _cargandoCercanas = false;
String? _paisCercanoDetectado;
String? _errorCercanas;
int _offsetBusqueda = 0;
String? _ultimoNombreBusqueda;
String? _ultimoPaisBusqueda;
String? _ultimoIdiomaBusqueda;
String? _ultimoTagBusqueda;
int? _ultimoMinBitrateBusqueda;
String? _errorCarga; String? _errorCarga;
// Identity-memoized derived lists so `context.select` consumers only
// rebuild when the underlying data actually changes (S4-R5).
final _memoPopulares = MemoLista<Emisora>();
final _memoTendencias = MemoLista<Emisora>();
final _memoFavoritos = MemoLista<Emisora>();
final _memoGrupos = MemoLista<GrupoFavoritos>();
final _memoCustom = MemoLista<Emisora>();
final _memoInicio = MemoLista<Emisora>();
final _memoDisponibles = MemoLista<Emisora>();
final _memoTimerPresets = MemoLista<int>();
static const _keyEmisoraPreferida = 'emisora_preferida_uuid_v1'; static const _keyEmisoraPreferida = 'emisora_preferida_uuid_v1';
static const _keyOrdenListas = 'orden_listas_emisoras_v1'; static const _keyOrdenListas = 'orden_listas_emisoras_v1';
static const _keyTimerSuenoPresets = 'timer_sueno_presets_segundos_v1'; static const _keyTimerSuenoPresets = 'timer_sueno_presets_segundos_v1';
@@ -151,38 +157,35 @@ class EstadoRadio extends ChangeNotifier {
); );
OrdenEmisoras _ordenListas = OrdenEmisoras.calidad; OrdenEmisoras _ordenListas = OrdenEmisoras.calidad;
List<Emisora> get populares => _ordenarEmisoras(_populares); List<Emisora> get populares => _memoPopulares.obtener([
List<Emisora> get tendencias => _ordenarEmisoras(_tendencias); _populares,
List<Emisora> get resultadosBusqueda => _ordenarEmisoras(_resultadosBusqueda); _ordenListas,
List<Emisora> get emisorasCercanas => _ordenarEmisoras(_emisorasCercanas); ], () => ordenarEmisoras(_populares, _ordenListas));
List<Emisora> get listaFavoritos => _ordenarEmisoras(_listaFavoritos); List<Emisora> get tendencias => _memoTendencias.obtener([
List<GrupoFavoritos> get gruposFavoritos => _tendencias,
List.unmodifiable(_gruposFavoritos); _ordenListas,
List<Emisora> get emisorasCustom => _ordenarEmisoras(_emisorasCustom); ], () => ordenarEmisoras(_tendencias, _ordenListas));
List<Emisora> get listaFavoritos => _memoFavoritos.obtener([
_listaFavoritos,
_ordenListas,
], () => ordenarEmisoras(_listaFavoritos, _ordenListas));
List<GrupoFavoritos> get gruposFavoritos => _memoGrupos.obtener([
_gruposFavoritos,
], () => List<GrupoFavoritos>.unmodifiable(_gruposFavoritos));
List<Emisora> get emisorasCustom => _memoCustom.obtener([
_emisorasCustom,
_ordenListas,
], () => ordenarEmisoras(_emisorasCustom, _ordenListas));
bool get cargandoPopulares => _cargandoPopulares; bool get cargandoPopulares => _cargandoPopulares;
bool get cargandoBusqueda => _cargandoBusqueda;
bool get cargandoMasBusqueda => _cargandoMasBusqueda;
bool get hayMasBusqueda => _hayMasBusqueda;
bool get cargandoCercanas => _cargandoCercanas;
String? get paisCercanoDetectado => _paisCercanoDetectado;
String? get errorCercanas => _errorCercanas;
String? get error => _errorCarga; String? get error => _errorCarga;
Emisora? get emisoraActual => _emisoraSeleccionada ?? audio.emisoraActual; Emisora? get emisoraActual => _emisoraSeleccionada ?? audio.emisoraActual;
Emisora? get emisoraPreferida => _resolverEmisoraPreferida(); Emisora? get emisoraPreferida => _resolverEmisoraPreferida();
String? get emisoraPreferidaUuid => emisoraPreferida?.uuid; String? get emisoraPreferidaUuid => emisoraPreferida?.uuid;
Stream<EstadoReproduccion> get estadoStream => audio.estadoStream; Stream<EstadoReproduccion> get estadoStream => audio.estadoStream;
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
PresetEcualizador get presetEcualizador => ecualizador.presetActual;
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
PresetEcualizador get presetPrincipalEcualizador =>
ecualizador.presetPrincipal;
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
bool get ecualizadorActivo => ecualizador.activo;
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
bool get ecualizadorDisponible => ecualizador.disponible;
OrdenEmisoras get ordenListas => _ordenListas; OrdenEmisoras get ordenListas => _ordenListas;
List<int> get timerSuenoPresetsSegundos => List<int> get timerSuenoPresetsSegundos => _memoTimerPresets.obtener([
List<int>.unmodifiable(_timerSuenoPresetsSegundos); _timerSuenoPresetsSegundos,
], () => List<int>.unmodifiable(_timerSuenoPresetsSegundos));
bool get emisoraActualEsFavorita { bool get emisoraActualEsFavorita {
final actual = emisoraActual; final actual = emisoraActual;
@@ -190,50 +193,51 @@ class EstadoRadio extends ChangeNotifier {
return _listaFavoritos.any((e) => e.uuid == actual.uuid); return _listaFavoritos.any((e) => e.uuid == actual.uuid);
} }
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
bool get emisoraActualTienePresetPropio =>
ecualizador.emisoraActualTienePresetPropio;
EstadoGrabacionRadio get estadoGrabacion => grabacion.estado;
bool get grabacionActiva => grabacion.estado.activa;
String? get directorioGrabacion => grabacion.directorioConfigurado;
int get maxBytesGrabacion => grabacion.maxBytes;
File? get ultimaGrabacion => grabacion.ultimoArchivo;
/// Lista principal (home): custom + populares, sin duplicados. /// Lista principal (home): custom + populares, sin duplicados.
List<Emisora> get emisorasInicio { List<Emisora> get emisorasInicio =>
final mapa = <String, Emisora>{}; _memoInicio.obtener([_emisorasCustom, _populares], () {
for (final emisora in _emisorasCustom) { final mapa = <String, Emisora>{};
mapa[emisora.uuid] = emisora; for (final emisora in _emisorasCustom) {
} mapa[emisora.uuid] = emisora;
for (final emisora in _populares) { }
mapa.putIfAbsent(emisora.uuid, () => emisora); for (final emisora in _populares) {
} mapa.putIfAbsent(emisora.uuid, () => emisora);
return mapa.values.toList(); }
} return mapa.values.toList();
});
List<Emisora> get emisorasDisponiblesPreferencia { List<Emisora> get emisorasDisponiblesPreferencia => _memoDisponibles.obtener(
final mapa = <String, Emisora>{}; [
for (final emisora in _listaFavoritos) { _listaFavoritos,
mapa[emisora.uuid] = emisora; _emisorasCustom,
} _populares,
for (final emisora in _emisorasCustom) { _tendencias,
mapa.putIfAbsent(emisora.uuid, () => emisora); busqueda.resultados,
} busqueda.cercanas,
for (final emisora in _populares) { ],
mapa.putIfAbsent(emisora.uuid, () => emisora); () {
} final mapa = <String, Emisora>{};
for (final emisora in _tendencias) { for (final emisora in _listaFavoritos) {
mapa.putIfAbsent(emisora.uuid, () => emisora); mapa[emisora.uuid] = emisora;
} }
for (final emisora in _resultadosBusqueda) { for (final emisora in _emisorasCustom) {
mapa.putIfAbsent(emisora.uuid, () => emisora); mapa.putIfAbsent(emisora.uuid, () => emisora);
} }
for (final emisora in _emisorasCercanas) { for (final emisora in _populares) {
mapa.putIfAbsent(emisora.uuid, () => emisora); mapa.putIfAbsent(emisora.uuid, () => emisora);
} }
return mapa.values.toList(); for (final emisora in _tendencias) {
} mapa.putIfAbsent(emisora.uuid, () => emisora);
}
for (final emisora in busqueda.resultados) {
mapa.putIfAbsent(emisora.uuid, () => emisora);
}
for (final emisora in busqueda.cercanas) {
mapa.putIfAbsent(emisora.uuid, () => emisora);
}
return mapa.values.toList();
},
);
Future<void> inicializar() { Future<void> inicializar() {
_initFuture ??= _init(); _initFuture ??= _init();
@@ -264,23 +268,13 @@ class EstadoRadio extends ChangeNotifier {
if ((estado == EstadoReproduccion.detenido || if ((estado == EstadoReproduccion.detenido ||
estado == EstadoReproduccion.pausado || estado == EstadoReproduccion.pausado ||
estado == EstadoReproduccion.error) && estado == EstadoReproduccion.error) &&
grabacion.estado.activa) { grabacion.activa) {
unawaited(grabacion.detener()); unawaited(grabacion.detener());
} }
notifyListeners(); notifyListeners();
}); });
} }
void _escucharGrabacion() {
_suscripcionGrabacion = grabacion.estadoStream.listen((estado) {
if (estado.tipo == EstadoGrabacionRadioTipo.error &&
estado.error != null) {
_errorController.add(_textos.radioRecordingError(estado.error!));
}
notifyListeners();
});
}
Future<void> cargarPopulares() async { Future<void> cargarPopulares() async {
_cargandoPopulares = true; _cargandoPopulares = true;
_errorCarga = null; _errorCarga = null;
@@ -390,6 +384,8 @@ class EstadoRadio extends ChangeNotifier {
_ordenListas = orden; _ordenListas = orden;
final prefs = await _resolverPrefs(); final prefs = await _resolverPrefs();
await prefs.setString(_keyOrdenListas, orden.name); await prefs.setString(_keyOrdenListas, orden.name);
// Search owns its own listeners (S4-R3) but sorts with this preference.
busqueda.notificarCambioOrden();
notifyListeners(); notifyListeners();
} }
@@ -422,176 +418,9 @@ class EstadoRadio extends ChangeNotifier {
return disponibles.isEmpty ? null : disponibles.first; return disponibles.isEmpty ? null : disponibles.first;
} }
static const int _tamanoPaginaBusqueda = 30;
static const int _maxResultadosBusquedaEnMemoria = 180;
Future<void> buscar({
String? nombre,
String? pais,
String? idioma,
String? tag,
int? minBitrate,
}) async {
_ultimoNombreBusqueda = nombre;
_ultimoPaisBusqueda = pais;
_ultimoIdiomaBusqueda = idioma;
_ultimoTagBusqueda = tag;
_ultimoMinBitrateBusqueda = minBitrate;
_offsetBusqueda = 0;
_hayMasBusqueda = true;
_cargandoBusqueda = true;
_resultadosBusqueda = [];
notifyListeners();
try {
final pagina = await _buscarPaginaFiltrada(
nombre: nombre,
pais: pais,
idioma: idioma,
tag: tag,
minBitrate: minBitrate,
);
_resultadosBusqueda = pagina;
} catch (_) {
_errorController.add(_textos.radioSearchError);
} finally {
_cargandoBusqueda = false;
notifyListeners();
}
}
Future<void> cargarMasBusqueda() async {
if (_cargandoBusqueda || _cargandoMasBusqueda || !_hayMasBusqueda) return;
_cargandoMasBusqueda = true;
notifyListeners();
try {
final pagina = await _buscarPaginaFiltrada(
nombre: _ultimoNombreBusqueda,
pais: _ultimoPaisBusqueda,
idioma: _ultimoIdiomaBusqueda,
tag: _ultimoTagBusqueda,
minBitrate: _ultimoMinBitrateBusqueda,
);
final porUuid = <String, Emisora>{
for (final emisora in _resultadosBusqueda) emisora.uuid: emisora,
};
for (final emisora in pagina) {
porUuid[emisora.uuid] = emisora;
}
var nuevaLista = porUuid.values.toList();
if (nuevaLista.length > _maxResultadosBusquedaEnMemoria) {
nuevaLista = nuevaLista.sublist(
nuevaLista.length - _maxResultadosBusquedaEnMemoria,
);
}
_resultadosBusqueda = nuevaLista;
// _buscarPaginaFiltrada actualiza offset/hayMas usando páginas crudas.
_hayMasBusqueda = _hayMasBusqueda && pagina.isNotEmpty;
} catch (_) {
_errorController.add(_textos.radioLoadMoreStationsError);
} finally {
_cargandoMasBusqueda = false;
notifyListeners();
}
}
Future<List<Emisora>> _buscarPaginaFiltrada({
String? nombre,
String? pais,
String? idioma,
String? tag,
int? minBitrate,
}) async {
final acumuladas = <Emisora>[];
var intentos = 0;
while (intentos < 4 && acumuladas.isEmpty && _hayMasBusqueda) {
final pagina = await radio.buscar(
nombre: nombre,
pais: pais,
idioma: idioma,
tag: tag,
limit: _tamanoPaginaBusqueda,
offset: _offsetBusqueda,
);
_offsetBusqueda += pagina.length;
_hayMasBusqueda = pagina.length == _tamanoPaginaBusqueda;
acumuladas.addAll(_filtrarMinBitrate(pagina, minBitrate));
intentos++;
}
return acumuladas;
}
List<Emisora> _filtrarMinBitrate(List<Emisora> emisoras, int? minBitrate) {
if (minBitrate == null || minBitrate <= 0) return emisoras;
return emisoras.where((e) => (e.bitrate ?? 0) >= minBitrate).toList();
}
List<Emisora> _ordenarEmisoras(List<Emisora> emisoras) {
final ordenadas = List<Emisora>.from(emisoras);
switch (_ordenListas) {
case OrdenEmisoras.nombre:
ordenadas.sort(
(a, b) => a.nombre.toLowerCase().compareTo(b.nombre.toLowerCase()),
);
case OrdenEmisoras.calidad:
ordenadas.sort((a, b) {
final porBitrate = (b.bitrate ?? 0).compareTo(a.bitrate ?? 0);
if (porBitrate != 0) return porBitrate;
return 0;
});
}
return ordenadas;
}
Future<void> cargarEmisorasCercanas() async {
_cargandoCercanas = true;
_errorCercanas = null;
notifyListeners();
try {
var pais = PlatformDispatcher.instance.locale.countryCode;
final servicioActivo = await Geolocator.isLocationServiceEnabled();
if (servicioActivo) {
var permiso = await Geolocator.checkPermission();
if (permiso == LocationPermission.denied) {
permiso = await Geolocator.requestPermission();
}
if (permiso == LocationPermission.always ||
permiso == LocationPermission.whileInUse) {
final posicion = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.low,
timeLimit: Duration(seconds: 8),
),
);
final marcas = await placemarkFromCoordinates(
posicion.latitude,
posicion.longitude,
);
if (marcas.isNotEmpty) {
pais = marcas.first.isoCountryCode ?? pais;
}
}
}
if (pais == null || pais.isEmpty) {
throw StateError('nearby-region-not-detected');
}
_paisCercanoDetectado = pais;
_emisorasCercanas = _filtrarMinBitrate(
await radio.buscar(pais: pais, limit: 30),
_ultimoMinBitrateBusqueda,
);
} catch (_) {
_errorCercanas = _textos.radioNearbyStationsError;
_emisorasCercanas = [];
} finally {
_cargandoCercanas = false;
notifyListeners();
}
}
Future<void> reproducir(Emisora emisora) async { Future<void> reproducir(Emisora emisora) async {
final revision = ++_revisionReproduccion; final revision = ++_revisionReproduccion;
if (grabacion.estado.activa) { if (grabacion.activa) {
await grabacion.detener(); await grabacion.detener();
} }
_emisoraSeleccionada = emisora; _emisoraSeleccionada = emisora;
@@ -623,83 +452,16 @@ class EstadoRadio extends ChangeNotifier {
} }
} }
Future<void> iniciarGrabacion({Duration? duracion}) async {
final actual = emisoraActual;
if (actual == null) {
_errorController.add(_textos.recordingSelectStationFirst);
return;
}
try {
await grabacion.iniciar(actual, duracion: duracion);
} catch (e) {
_errorController.add(_textos.recordingStartError(e.toString()));
}
}
Future<void> detenerGrabacion() => grabacion.detener();
Future<void> detenerReproduccion() async { Future<void> detenerReproduccion() async {
if (grabacion.estado.activa) { if (grabacion.activa) {
await grabacion.detener(); await grabacion.detener();
} }
await audio.detener(); await audio.detener();
notifyListeners(); notifyListeners();
} }
Future<void> cambiarMaxBytesGrabacion(int bytes) async {
await grabacion.guardarMaxBytes(bytes);
notifyListeners();
}
Future<bool> abrirDirectorioGrabacion() async {
final ruta = await directorioGrabacionEfectivo();
await Directory(ruta).create(recursive: true);
if (!kIsWeb && Platform.isAndroid) {
final abierto = await _fileActionsChannel.invokeMethod<bool>(
'viewDirectory',
{'path': ruta},
);
return abierto ?? false;
}
final uri = Uri.directory(ruta);
return launchUrl(uri, mode: LaunchMode.externalApplication);
}
Future<bool> abrirUltimaGrabacion() async {
final archivo = ultimaGrabacion;
if (archivo == null || !await archivo.exists()) {
debugPrint('[PluriWave][recordings] last recording missing');
return false;
}
debugPrint('[PluriWave][recordings] opening last file: ${archivo.path}');
if (!kIsWeb && Platform.isAndroid) {
final abierto = await _fileActionsChannel.invokeMethod<bool>('openFile', {
'path': archivo.path,
'mimeType': 'audio/*',
});
return abierto ?? false;
}
return launchUrl(
Uri.file(archivo.path),
mode: LaunchMode.externalApplication,
);
}
Future<void> cambiarDirectorioGrabacion(String path) async {
await grabacion.guardarDirectorio(path);
notifyListeners();
}
Future<void> restaurarDirectorioGrabacion() async {
await grabacion.limpiarDirectorioConfigurado();
notifyListeners();
}
Future<String> directorioGrabacionEfectivo() =>
grabacion.directorioEfectivo();
Future<void> togglePlay() async { Future<void> togglePlay() async {
if (audio.estaSonando && grabacion.estado.activa) { if (audio.estaSonando && grabacion.activa) {
await grabacion.detener(); await grabacion.detener();
} }
await audio.togglePlay(); await audio.togglePlay();
@@ -709,7 +471,7 @@ class EstadoRadio extends ChangeNotifier {
Future<bool> toggleFavorito(Emisora emisora) async { Future<bool> toggleFavorito(Emisora emisora) async {
final esFav = await favoritos.toggleFavorito(emisora); final esFav = await favoritos.toggleFavorito(emisora);
if (!esFav) { if (!esFav) {
await deshabilitarPresetEcualizadorPorEmisora( await ecualizador.deshabilitarPresetPorEmisora(
emisora.uuid, emisora.uuid,
notificar: false, notificar: false,
); );
@@ -720,68 +482,6 @@ class EstadoRadio extends ChangeNotifier {
Future<bool> esFavorito(String uuid) => favoritos.esFavorito(uuid); Future<bool> esFavorito(String uuid) => favoritos.esFavorito(uuid);
// ── Ecualizador ───────────────────────────────────────────────────────────
// Transition bridge (S4a): EQ state lives in EstadoEcualizador; these
// delegating members keep legacy call sites compiling. They do NOT notify
// EstadoRadio listeners (S4-R1-A).
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
bool tienePresetEcualizadorPorEmisora(String uuid) =>
ecualizador.tienePresetPorEmisora(uuid);
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
PresetEcualizador? presetEcualizadorPorEmisora(String uuid) =>
ecualizador.presetPorEmisora(uuid);
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
Future<void> cambiarPresetPrincipalEcualizador(
PresetEcualizador preset, {
bool notificar = true,
}) => ecualizador.cambiarPresetPrincipal(preset, notificar: notificar);
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
Future<void> guardarPresetEcualizadorPorEmisora(
String uuid,
PresetEcualizador preset, {
bool notificar = true,
}) => ecualizador.guardarPresetPorEmisora(uuid, preset, notificar: notificar);
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
Future<void> habilitarPresetEcualizadorPorEmisora(
String uuid, {
PresetEcualizador? base,
bool notificar = true,
}) => ecualizador.habilitarPresetPorEmisora(
uuid,
base: base,
notificar: notificar,
);
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
Future<void> deshabilitarPresetEcualizadorPorEmisora(
String uuid, {
bool notificar = true,
}) => ecualizador.deshabilitarPresetPorEmisora(uuid, notificar: notificar);
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
Future<void> cambiarModoEcualizadorEmisoraActual({
required bool usarPropio,
}) => ecualizador.cambiarModoEmisoraActual(usarPropio: usarPropio);
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
Future<void> cambiarEcualizadorActivo(bool activo) =>
ecualizador.cambiarActivo(activo);
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
Future<void> cambiarPresetEcualizador(
PresetEcualizador preset, {
bool guardarPorEmisora = true,
}) => ecualizador.cambiarPreset(preset, guardarPorEmisora: guardarPorEmisora);
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
Future<void> cambiarBandaEcualizador(int index, double db) =>
ecualizador.cambiarBanda(index, db);
// ── Emisoras personalizadas ─────────────────────────────────────────────── // ── Emisoras personalizadas ───────────────────────────────────────────────
Future<File> _archivoCustom() async { Future<File> _archivoCustom() async {
@@ -820,8 +520,11 @@ class EstadoRadio extends ChangeNotifier {
} }
Future<void> agregarEmisoraCustom(Emisora emisora) async { Future<void> agregarEmisoraCustom(Emisora emisora) async {
_emisorasCustom.removeWhere((e) => e.uuid == emisora.uuid); // Reassign (not mutate) so identity-memoized views refresh (S4-R5).
_emisorasCustom.add(emisora); _emisorasCustom = [
..._emisorasCustom.where((e) => e.uuid != emisora.uuid),
emisora,
];
await _guardarEmisorasCustom(); await _guardarEmisorasCustom();
notifyListeners(); notifyListeners();
} }
@@ -831,7 +534,7 @@ class EstadoRadio extends ChangeNotifier {
agregarEmisoraCustom(emisora); agregarEmisoraCustom(emisora);
Future<void> eliminarEmisoraCustom(String uuid) async { Future<void> eliminarEmisoraCustom(String uuid) async {
_emisorasCustom.removeWhere((e) => e.uuid == uuid); _emisorasCustom = _emisorasCustom.where((e) => e.uuid != uuid).toList();
await _guardarEmisorasCustom(); await _guardarEmisorasCustom();
notifyListeners(); notifyListeners();
} }
@@ -1039,11 +742,11 @@ class EstadoRadio extends ChangeNotifier {
@override @override
void dispose() { void dispose() {
_suscripcionEstadoAudio?.cancel(); _suscripcionEstadoAudio?.cancel();
_suscripcionGrabacion?.cancel();
_errorController.close(); _errorController.close();
ecualizador.dispose(); ecualizador.dispose();
busqueda.dispose();
grabacion.dispose();
audio.dispose(); audio.dispose();
unawaited(grabacion.dispose());
timer.dispose(); timer.dispose();
super.dispose(); super.dispose();
} }
+55
View File
@@ -0,0 +1,55 @@
import '../modelos/emisora.dart';
/// User-selectable ordering for every station list in the app.
enum OrdenEmisoras { nombre, calidad }
/// Returns a sorted COPY of [emisoras] according to [orden].
List<Emisora> ordenarEmisoras(List<Emisora> emisoras, OrdenEmisoras orden) {
final ordenadas = List<Emisora>.from(emisoras);
switch (orden) {
case OrdenEmisoras.nombre:
ordenadas.sort(
(a, b) => a.nombre.toLowerCase().compareTo(b.nombre.toLowerCase()),
);
case OrdenEmisoras.calidad:
ordenadas.sort((a, b) {
final porBitrate = (b.bitrate ?? 0).compareTo(a.bitrate ?? 0);
if (porBitrate != 0) return porBitrate;
return 0;
});
}
return ordenadas;
}
/// Identity-memoized derived list (S4-R5).
///
/// Derived-list getters used to return a fresh copy on every read, which made
/// `context.select` rebuild on EVERY notification (lists compare by identity).
/// This memo recomputes only when one of the source [claves] changes identity,
/// so unrelated notifications (e.g. audio buffer events) stop rebuilding the
/// screens that select these lists.
class MemoLista<T> {
List<Object?>? _claves;
List<T>? _resultado;
List<T> obtener(List<Object?> claves, List<T> Function() calcular) {
final anteriores = _claves;
final resultado = _resultado;
if (anteriores != null &&
resultado != null &&
anteriores.length == claves.length) {
var iguales = true;
for (var i = 0; i < claves.length; i++) {
if (!identical(anteriores[i], claves[i])) {
iguales = false;
break;
}
}
if (iguales) return resultado;
}
final nuevo = calcular();
_claves = List<Object?>.of(claves);
_resultado = nuevo;
return nuevo;
}
}
+50 -32
View File
@@ -9,6 +9,7 @@ import 'package:share_plus/share_plus.dart' show Share, XFile;
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import '../estado/estado_ecualizador.dart'; import '../estado/estado_ecualizador.dart';
import '../estado/estado_grabacion.dart';
import '../estado/estado_idioma.dart'; import '../estado/estado_idioma.dart';
import '../estado/estado_radio.dart'; import '../estado/estado_radio.dart';
import '../l10n/display_names.dart'; import '../l10n/display_names.dart';
@@ -85,7 +86,7 @@ class _SeccionGrabaciones extends StatelessWidget {
const _SeccionGrabaciones(); const _SeccionGrabaciones();
Future<void> _seleccionarRuta(BuildContext context) async { Future<void> _seleccionarRuta(BuildContext context) async {
final estado = context.read<EstadoRadio>(); final estado = context.read<EstadoGrabacion>();
final messenger = ScaffoldMessenger.of(context); final messenger = ScaffoldMessenger.of(context);
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final ruta = await FilePicker.platform.getDirectoryPath( final ruta = await FilePicker.platform.getDirectoryPath(
@@ -93,7 +94,7 @@ class _SeccionGrabaciones extends StatelessWidget {
); );
if (ruta == null) return; if (ruta == null) return;
try { try {
await estado.cambiarDirectorioGrabacion(ruta); await estado.cambiarDirectorio(ruta);
if (!context.mounted) return; if (!context.mounted) return;
messenger.showSnackBar( messenger.showSnackBar(
SnackBar(content: Text(l10n.recordingsPathUpdated)), SnackBar(content: Text(l10n.recordingsPathUpdated)),
@@ -107,10 +108,10 @@ class _SeccionGrabaciones extends StatelessWidget {
} }
Future<void> _restaurarRuta(BuildContext context) async { Future<void> _restaurarRuta(BuildContext context) async {
final estado = context.read<EstadoRadio>(); final estado = context.read<EstadoGrabacion>();
final messenger = ScaffoldMessenger.of(context); final messenger = ScaffoldMessenger.of(context);
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
await estado.restaurarDirectorioGrabacion(); await estado.restaurarDirectorio();
if (!context.mounted) return; if (!context.mounted) return;
messenger.showSnackBar( messenger.showSnackBar(
SnackBar(content: Text(l10n.recordingsDefaultFolderRestored)), SnackBar(content: Text(l10n.recordingsDefaultFolderRestored)),
@@ -118,11 +119,11 @@ class _SeccionGrabaciones extends StatelessWidget {
} }
Future<void> _abrirCarpeta(BuildContext context) async { Future<void> _abrirCarpeta(BuildContext context) async {
final estado = context.read<EstadoRadio>(); final estado = context.read<EstadoGrabacion>();
final messenger = ScaffoldMessenger.of(context); final messenger = ScaffoldMessenger.of(context);
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
try { try {
final abierto = await estado.abrirDirectorioGrabacion(); final abierto = await estado.abrirDirectorio();
if (!context.mounted) return; if (!context.mounted) return;
if (!abierto) { if (!abierto) {
messenger.showSnackBar( messenger.showSnackBar(
@@ -138,9 +139,9 @@ class _SeccionGrabaciones extends StatelessWidget {
} }
Future<void> _editarTamanoMaximo(BuildContext context) async { Future<void> _editarTamanoMaximo(BuildContext context) async {
final estado = context.read<EstadoRadio>(); final estado = context.read<EstadoGrabacion>();
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final actualMb = _bytesAMegabytes(estado.maxBytesGrabacion); final actualMb = _bytesAMegabytes(estado.maxBytes);
final controller = TextEditingController(text: actualMb.toString()); final controller = TextEditingController(text: actualMb.toString());
final nuevoMb = await showModalBottomSheet<int>( final nuevoMb = await showModalBottomSheet<int>(
@@ -186,7 +187,7 @@ class _SeccionGrabaciones extends StatelessWidget {
); );
controller.dispose(); controller.dispose();
if (nuevoMb == null || !context.mounted) return; if (nuevoMb == null || !context.mounted) return;
await estado.cambiarMaxBytesGrabacion(nuevoMb * 1024 * 1024); await estado.cambiarMaxBytes(nuevoMb * 1024 * 1024);
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.recordingsMaxSizeSaved(nuevoMb))), SnackBar(content: Text(l10n.recordingsMaxSizeSaved(nuevoMb))),
@@ -198,7 +199,9 @@ class _SeccionGrabaciones extends StatelessWidget {
@override @override
Widget build(BuildContext context) { 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); final l10n = AppLocalizations.of(context);
return PluriGlassSurface( return PluriGlassSurface(
@@ -216,7 +219,7 @@ class _SeccionGrabaciones extends StatelessWidget {
], ],
), ),
FutureBuilder<String>( FutureBuilder<String>(
future: estado.directorioGrabacionEfectivo(), future: estado.directorioEfectivo(),
builder: builder:
(ctx, snap) => ListTile( (ctx, snap) => ListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
@@ -256,9 +259,7 @@ class _SeccionGrabaciones extends StatelessWidget {
leading: const Icon(Icons.sd_storage_rounded), leading: const Icon(Icons.sd_storage_rounded),
title: Text(l10n.recordingsMaxSizeTitle), title: Text(l10n.recordingsMaxSizeTitle),
subtitle: Text( subtitle: Text(
l10n.recordingsMaxSizeSubtitle( l10n.recordingsMaxSizeSubtitle(_bytesAMegabytes(estado.maxBytes)),
_bytesAMegabytes(estado.maxBytesGrabacion),
),
), ),
onTap: () => _editarTamanoMaximo(context), onTap: () => _editarTamanoMaximo(context),
), ),
@@ -301,8 +302,10 @@ class _SeccionTimerSueno extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final estado = context.watch<EstadoRadio>(); // S4-R5: scoped select — rebuilds only when the presets list changes.
final presets = estado.timerSuenoPresetsSegundos; final presets = context.select<EstadoRadio, List<int>>(
(e) => e.timerSuenoPresetsSegundos,
);
return PluriGlassSurface( return PluriGlassSurface(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -654,7 +657,10 @@ class _SeccionOrdenListas extends StatelessWidget {
@override @override
Widget build(BuildContext context) { 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); final l10n = AppLocalizations.of(context);
return PluriGlassSurface( return PluriGlassSurface(
child: Column( child: Column(
@@ -684,9 +690,9 @@ class _SeccionOrdenListas extends StatelessWidget {
label: Text(l10n.stationOrderByQuality), label: Text(l10n.stationOrderByQuality),
), ),
], ],
selected: {estado.ordenListas}, selected: {orden},
onSelectionChanged: (value) { onSelectionChanged: (value) {
estado.cambiarOrdenListas(value.first); context.read<EstadoRadio>().cambiarOrdenListas(value.first);
}, },
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -790,9 +796,11 @@ class _SeccionGruposFavoritos extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final estado = context.watch<EstadoRadio>();
final l10n = AppLocalizations.of(context); 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( return PluriGlassSurface(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -858,11 +866,18 @@ class _SeccionEmisoraPreferida extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final estado = context.watch<EstadoRadio>();
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final favoritas = estado.listaFavoritos; // S4-R5: scoped selects over identity-memoized getters.
final preferida = estado.emisoraPreferida; final favoritas = context.select<EstadoRadio, List<Emisora>>(
final opciones = _opciones(estado, preferida); (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( return PluriGlassSurface(
child: Column( child: Column(
@@ -947,11 +962,12 @@ class _SeccionEmisoraPreferida extends StatelessWidget {
); );
} }
List<Emisora> _opciones(EstadoRadio estado, Emisora? preferida) { List<Emisora> _opciones(
final base = List<Emisora> favoritas,
estado.listaFavoritos.isNotEmpty List<Emisora> disponibles,
? estado.listaFavoritos Emisora? preferida,
: estado.emisorasDisponiblesPreferencia; ) {
final base = favoritas.isNotEmpty ? favoritas : disponibles;
final mapa = <String, Emisora>{ final mapa = <String, Emisora>{
for (final emisora in base) emisora.uuid: emisora, for (final emisora in base) emisora.uuid: emisora,
}; };
@@ -967,8 +983,10 @@ class _SeccionEmisoras extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final estado = context.watch<EstadoRadio>(); // S4-R5: scoped select — rebuilds only when the custom list changes.
final custom = estado.emisorasCustom; final custom = context.select<EstadoRadio, List<Emisora>>(
(e) => e.emisorasCustom,
);
return PluriGlassSurface( return PluriGlassSurface(
child: Column( child: Column(
+39 -18
View File
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_animate/flutter_animate.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../estado/estado_radio.dart'; import '../estado/estado_busqueda.dart';
import '../l10n/gen/app_localizations.dart'; import '../l10n/gen/app_localizations.dart';
import '../widgets/pluri_glass_surface.dart'; import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_icon.dart'; import '../widgets/pluri_icon.dart';
@@ -58,7 +58,7 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
void _buscar() { void _buscar() {
final q = _controller.text.trim(); final q = _controller.text.trim();
context.read<EstadoRadio>().buscar( context.read<EstadoBusqueda>().buscar(
nombre: q.isNotEmpty ? q : null, nombre: q.isNotEmpty ? q : null,
pais: _paisSeleccionado, pais: _paisSeleccionado,
idioma: _idiomaSeleccionado, idioma: _idiomaSeleccionado,
@@ -68,7 +68,9 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
@override @override
Widget build(BuildContext context) { 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 theme = Theme.of(context);
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
@@ -85,7 +87,12 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
), ),
), ),
Padding( Padding(
padding: const EdgeInsets.fromLTRB(PluriLayout.horizontal, 10, PluriLayout.horizontal, 0), padding: const EdgeInsets.fromLTRB(
PluriLayout.horizontal,
10,
PluriLayout.horizontal,
0,
),
child: PluriGlassSurface( child: PluriGlassSurface(
padding: const EdgeInsets.all(10), padding: const EdgeInsets.all(10),
borderRadius: BorderRadius.circular(999), borderRadius: BorderRadius.circular(999),
@@ -132,7 +139,13 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
), ),
_seccionFiltroInt( _seccionFiltroInt(
l10n.searchMinQualityFilterLabel, 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, _calidadMinima,
(v) { (v) {
setState(() => _calidadMinima = v); setState(() => _calidadMinima = v);
@@ -144,7 +157,6 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
); );
} }
Widget _seccionFiltro( Widget _seccionFiltro(
String titulo, String titulo,
List<(String, String)> opciones, List<(String, String)> opciones,
@@ -153,7 +165,12 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
) { ) {
final theme = Theme.of(context); final theme = Theme.of(context);
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(PluriLayout.horizontal, 8, PluriLayout.horizontal, 0), padding: const EdgeInsets.fromLTRB(
PluriLayout.horizontal,
8,
PluriLayout.horizontal,
0,
),
child: PluriGlassSurface( child: PluriGlassSurface(
padding: const EdgeInsets.all(10), padding: const EdgeInsets.all(10),
child: Column( child: Column(
@@ -198,7 +215,12 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
) { ) {
final theme = Theme.of(context); final theme = Theme.of(context);
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(PluriLayout.horizontal, 8, PluriLayout.horizontal, 0), padding: const EdgeInsets.fromLTRB(
PluriLayout.horizontal,
8,
PluriLayout.horizontal,
0,
),
child: PluriGlassSurface( child: PluriGlassSurface(
padding: const EdgeInsets.all(10), padding: const EdgeInsets.all(10),
child: Column( 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); final l10n = AppLocalizations.of(context);
if (estado.cargandoBusqueda) { if (estado.cargando) {
return const SizedBox( return const SizedBox(
height: 220, height: 220,
child: Center(child: CircularProgressIndicator()), child: Center(child: CircularProgressIndicator()),
); );
} }
final resultados = estado.resultadosBusqueda; final resultados = estado.resultados;
if (resultados.isEmpty) { if (resultados.isEmpty) {
final sinFiltros = final sinFiltros =
@@ -255,8 +277,7 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
height: 260, height: 260,
child: PluriEmptyState( child: PluriEmptyState(
glyph: PluriIconGlyph.search, glyph: PluriIconGlyph.search,
title: title: sinFiltros ? l10n.searchEmptyTitle : l10n.searchNoResultsTitle,
sinFiltros ? l10n.searchEmptyTitle : l10n.searchNoResultsTitle,
subtitle: subtitle:
sinFiltros sinFiltros
? l10n.searchEmptySubtitle ? 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( return ListView.separated(
shrinkWrap: true, shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
@@ -274,16 +295,16 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
separatorBuilder: (_, __) => const SizedBox(height: 10), separatorBuilder: (_, __) => const SizedBox(height: 10),
itemBuilder: (context, i) { itemBuilder: (context, i) {
if (i >= resultados.length) { if (i >= resultados.length) {
if (!estado.cargandoMasBusqueda) { if (!estado.cargandoMas) {
Future<void>.microtask(estado.cargarMasBusqueda); Future<void>.microtask(estado.cargarMas);
} }
return const Padding( return const Padding(
padding: EdgeInsets.all(18), padding: EdgeInsets.all(18),
child: Center(child: CircularProgressIndicator()), child: Center(child: CircularProgressIndicator()),
); );
} }
if (i >= resultados.length - 5 && estado.hayMasBusqueda) { if (i >= resultados.length - 5 && estado.hayMas) {
Future<void>.microtask(estado.cargarMasBusqueda); Future<void>.microtask(estado.cargarMas);
} }
return TarjetaEmisora( return TarjetaEmisora(
emisora: resultados[i], emisora: resultados[i],
+51 -40
View File
@@ -19,9 +19,15 @@ class PantallaFavoritos extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final estado = context.watch<EstadoRadio>(); // S4-R5: no root watch — select only the fields this screen reads. The
final favoritos = estado.listaFavoritos; // getters are identity-memoized, so playback notifications that do not
final grupos = estado.gruposFavoritos; // 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); final l10n = AppLocalizations.of(context);
if (favoritos.isEmpty) { if (favoritos.isEmpty) {
@@ -49,16 +55,17 @@ class PantallaFavoritos extends StatelessWidget {
); );
} }
final gruposVisibles = grupos.isEmpty final gruposVisibles =
? [ grupos.isEmpty
GrupoFavoritos( ? [
id: GrupoFavoritos.sinAsignarId, GrupoFavoritos(
nombre: l10n.favoriteGroupsUnassigned, id: GrupoFavoritos.sinAsignarId,
orden: 0, nombre: l10n.favoriteGroupsUnassigned,
protegido: true, orden: 0,
), protegido: true,
] ),
: grupos; ]
: grupos;
return CustomScrollView( return CustomScrollView(
slivers: [ slivers: [
@@ -86,9 +93,10 @@ class PantallaFavoritos extends StatelessWidget {
_GrupoFavoritosPanel( _GrupoFavoritosPanel(
grupo: grupo, grupo: grupo,
grupos: gruposVisibles, grupos: gruposVisibles,
emisoras: favoritos emisoras:
.where((e) => e.grupoFavoritosId == grupo.id) favoritos
.toList(), .where((e) => e.grupoFavoritosId == grupo.id)
.toList(),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
], ],
@@ -125,7 +133,9 @@ class _GrupoFavoritosPanel extends StatelessWidget {
children: [ children: [
Row( Row(
children: [ children: [
Icon(grupo.esSinAsignar ? Icons.lock_rounded : Icons.folder_rounded), Icon(
grupo.esSinAsignar ? Icons.lock_rounded : Icons.folder_rounded,
),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
@@ -181,30 +191,31 @@ class _FavoritoItem extends StatelessWidget {
final seleccionado = await showModalBottomSheet<String>( final seleccionado = await showModalBottomSheet<String>(
context: context, context: context,
showDragHandle: true, showDragHandle: true,
builder: (ctx) => SafeArea( builder:
child: ListView( (ctx) => SafeArea(
shrinkWrap: true, child: ListView(
children: [ shrinkWrap: true,
Padding( children: [
padding: const EdgeInsets.fromLTRB(20, 4, 20, 12), Padding(
child: Text( padding: const EdgeInsets.fromLTRB(20, 4, 20, 12),
l10n.favoriteGroupsAssign, child: Text(
style: Theme.of(ctx).textTheme.titleLarge, 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,
), ),
title: Text(_nombreVisible(l10n, grupo)), for (final grupo in grupos)
onTap: () => Navigator.pop(ctx, grupo.id), 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; if (seleccionado == null || !context.mounted) return;
await context.read<EstadoRadio>().asignarGrupoFavorito( 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:provider/provider.dart';
import 'package:shimmer/shimmer.dart' as shimmer; import 'package:shimmer/shimmer.dart' as shimmer;
import '../estado/estado_busqueda.dart';
import '../estado/estado_radio.dart'; import '../estado/estado_radio.dart';
import '../l10n/gen/app_localizations.dart'; import '../l10n/gen/app_localizations.dart';
import '../modelos/emisora.dart';
import '../widgets/pluri_glass_surface.dart'; import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_icon.dart'; import '../widgets/pluri_icon.dart';
import '../widgets/pluri_layout.dart'; import '../widgets/pluri_layout.dart';
@@ -40,20 +42,25 @@ class _PantallaInicioState extends State<PantallaInicio> {
@override @override
Widget build(BuildContext context) { 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 theme = Theme.of(context);
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final error = context.select<EstadoRadio, String?>((e) => e.error);
return RefreshIndicator( return RefreshIndicator(
onRefresh: estado.cargarPopulares, onRefresh: () => context.read<EstadoRadio>().cargarPopulares(),
child: CustomScrollView( child: CustomScrollView(
slivers: [ slivers: [
SliverToBoxAdapter(child: _heroHeader(context, estado, l10n)), SliverToBoxAdapter(child: _heroHeader(context, l10n)),
SliverToBoxAdapter(child: _seccionCercanas(estado, theme, l10n)), SliverToBoxAdapter(child: _seccionCercanas(context, theme, l10n)),
SliverToBoxAdapter(child: _seccionTendencias(estado, theme, l10n)), SliverToBoxAdapter(child: _seccionTendencias(context, theme, l10n)),
SliverToBoxAdapter(child: _chipGeneros(context, theme, l10n)), SliverToBoxAdapter(child: _chipGeneros(context, theme, l10n)),
if (estado.error != null) if (error != null)
SliverToBoxAdapter(child: _errorBanner(estado, theme, l10n)), SliverToBoxAdapter(
child: _errorBanner(context, error, theme, l10n),
),
SliverPadding( SliverPadding(
padding: const EdgeInsets.fromLTRB( padding: const EdgeInsets.fromLTRB(
PluriLayout.horizontal, PluriLayout.horizontal,
@@ -61,30 +68,29 @@ class _PantallaInicioState extends State<PantallaInicio> {
PluriLayout.horizontal, PluriLayout.horizontal,
PluriLayout.bottomChromeInset, PluriLayout.bottomChromeInset,
), ),
sliver: _gridEmisoras(estado, l10n), sliver: _gridEmisoras(context, l10n),
), ),
], ],
), ),
); );
} }
Widget _heroHeader( Widget _heroHeader(BuildContext context, AppLocalizations l10n) {
BuildContext context, final totalEmisoras = context.select<EstadoRadio, int>(
EstadoRadio estado, (e) => e.emisorasInicio.length,
AppLocalizations l10n, );
) {
return PluriScreenHeader( return PluriScreenHeader(
title: l10n.appTitle, title: l10n.appTitle,
subtitle: l10n.homeScreenSubtitle, subtitle: l10n.homeScreenSubtitle,
glyph: PluriIconGlyph.home, glyph: PluriIconGlyph.home,
primaryActionLabel: l10n.exploreStations, primaryActionLabel: l10n.exploreStations,
onPrimaryAction: estado.cargarPopulares, onPrimaryAction: () => context.read<EstadoRadio>().cargarPopulares(),
trailing: Column( trailing: Column(
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
PluriStatusPill( PluriStatusPill(
icon: Icons.public_rounded, icon: Icons.public_rounded,
label: l10n.stationsCount(estado.emisorasInicio.length), label: l10n.stationsCount(totalEmisoras),
accent: Theme.of(context).colorScheme.secondary, accent: Theme.of(context).colorScheme.secondary,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -95,11 +101,13 @@ class _PantallaInicioState extends State<PantallaInicio> {
} }
Widget _seccionCercanas( Widget _seccionCercanas(
EstadoRadio estado, BuildContext context,
ThemeData theme, ThemeData theme,
AppLocalizations l10n, AppLocalizations l10n,
) { ) {
final pais = estado.paisCercanoDetectado; // Nearby stations live in EstadoBusqueda (S4-R3).
final busqueda = context.watch<EstadoBusqueda>();
final pais = busqueda.paisCercanoDetectado;
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB( padding: const EdgeInsets.fromLTRB(
PluriLayout.horizontal, PluriLayout.horizontal,
@@ -124,11 +132,11 @@ class _PantallaInicioState extends State<PantallaInicio> {
), ),
TextButton.icon( TextButton.icon(
onPressed: onPressed:
estado.cargandoCercanas busqueda.cargandoCercanas
? null ? null
: estado.cargarEmisorasCercanas, : busqueda.cargarEmisorasCercanas,
icon: icon:
estado.cargandoCercanas busqueda.cargandoCercanas
? const SizedBox( ? const SizedBox(
width: 16, width: 16,
height: 16, height: 16,
@@ -139,23 +147,23 @@ class _PantallaInicioState extends State<PantallaInicio> {
), ),
], ],
), ),
if (estado.errorCercanas != null) if (busqueda.errorCercanas != null)
Text( Text(
estado.errorCercanas!, busqueda.errorCercanas!,
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error, color: theme.colorScheme.error,
), ),
), ),
if (estado.emisorasCercanas.isNotEmpty) ...[ if (busqueda.cercanas.isNotEmpty) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
SizedBox( SizedBox(
height: 76, height: 76,
child: ListView.separated( child: ListView.separated(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
itemCount: estado.emisorasCercanas.length, itemCount: busqueda.cercanas.length,
separatorBuilder: (_, __) => const SizedBox(width: 8), separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (context, i) { itemBuilder: (context, i) {
final emisora = estado.emisorasCercanas[i]; final emisora = busqueda.cercanas[i];
return SizedBox( return SizedBox(
width: 260, width: 260,
child: TarjetaEmisora( child: TarjetaEmisora(
@@ -175,10 +183,16 @@ class _PantallaInicioState extends State<PantallaInicio> {
} }
Widget _seccionTendencias( Widget _seccionTendencias(
EstadoRadio estado, BuildContext context,
ThemeData theme, ThemeData theme,
AppLocalizations l10n, AppLocalizations l10n,
) { ) {
final cargando = context.select<EstadoRadio, bool>(
(e) => e.cargandoPopulares,
);
final tendencias = context.select<EstadoRadio, List<Emisora>>(
(e) => e.tendencias,
);
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB( padding: const EdgeInsets.fromLTRB(
PluriLayout.horizontal, PluriLayout.horizontal,
@@ -196,7 +210,7 @@ class _PantallaInicioState extends State<PantallaInicio> {
SizedBox( SizedBox(
height: 56, height: 56,
child: child:
estado.cargandoPopulares cargando
? ListView.separated( ? ListView.separated(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
itemCount: 5, itemCount: 5,
@@ -205,10 +219,10 @@ class _PantallaInicioState extends State<PantallaInicio> {
) )
: ListView.separated( : ListView.separated(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
itemCount: estado.tendencias.length, itemCount: tendencias.length,
separatorBuilder: (_, __) => const SizedBox(width: 8), separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (context, i) { itemBuilder: (context, i) {
final e = estado.tendencias[i]; final e = tendencias[i];
return ActionChip( return ActionChip(
avatar: const Icon( avatar: const Icon(
Icons.graphic_eq_rounded, Icons.graphic_eq_rounded,
@@ -259,7 +273,7 @@ class _PantallaInicioState extends State<PantallaInicio> {
_generoSeleccionado = seleccionado ? null : g; _generoSeleccionado = seleccionado ? null : g;
}); });
if (!seleccionado) { if (!seleccionado) {
context.read<EstadoRadio>().buscar(tag: g); context.read<EstadoBusqueda>().buscar(tag: g);
} else { } else {
context.read<EstadoRadio>().cargarPopulares(); context.read<EstadoRadio>().cargarPopulares();
} }
@@ -274,7 +288,8 @@ class _PantallaInicioState extends State<PantallaInicio> {
} }
Widget _errorBanner( Widget _errorBanner(
EstadoRadio estado, BuildContext context,
String error,
ThemeData theme, ThemeData theme,
AppLocalizations l10n, AppLocalizations l10n,
) { ) {
@@ -286,9 +301,9 @@ class _PantallaInicioState extends State<PantallaInicio> {
children: [ children: [
Icon(Icons.wifi_off, color: theme.colorScheme.error), Icon(Icons.wifi_off, color: theme.colorScheme.error),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded(child: Text(estado.error!)), Expanded(child: Text(error)),
TextButton( TextButton(
onPressed: estado.cargarPopulares, onPressed: () => context.read<EstadoRadio>().cargarPopulares(),
child: Text(l10n.retryAction), 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 = final emisoras =
_generoSeleccionado != null porGenero
? estado.resultadosBusqueda ? context.select<EstadoBusqueda, List<Emisora>>((b) => b.resultados)
: estado.emisorasInicio; : context.select<EstadoRadio, List<Emisora>>(
(e) => e.emisorasInicio,
);
final cargando = final cargando =
estado.cargandoPopulares || context.select<EstadoRadio, bool>((e) => e.cargandoPopulares) ||
(_generoSeleccionado != null && estado.cargandoBusqueda); (porGenero && context.select<EstadoBusqueda, bool>((b) => b.cargando));
if (cargando) { if (cargando) {
return SliverGrid( return SliverGrid(
+17 -11
View File
@@ -5,6 +5,7 @@ import 'package:provider/provider.dart';
import 'package:shimmer/shimmer.dart'; import 'package:shimmer/shimmer.dart';
import '../estado/estado_ecualizador.dart'; import '../estado/estado_ecualizador.dart';
import '../estado/estado_grabacion.dart';
import '../estado/estado_radio.dart'; import '../estado/estado_radio.dart';
import '../l10n/gen/app_localizations.dart'; import '../l10n/gen/app_localizations.dart';
import '../modelos/emisora.dart'; import '../modelos/emisora.dart';
@@ -177,7 +178,7 @@ class _PantallaReproductorState extends State<PantallaReproductor>
emisora: emisoraActiva, emisora: emisoraActiva,
).animate().fadeIn(delay: 300.ms).slideY(begin: 0.3), ).animate().fadeIn(delay: 300.ms).slideY(begin: 0.3),
const SizedBox(height: 14), const SizedBox(height: 14),
_GrabacionWidget(estado: estado).animate().fadeIn(delay: 360.ms), const _GrabacionWidget().animate().fadeIn(delay: 360.ms),
const SizedBox(height: 14), const SizedBox(height: 14),
_TimerWidget(estado: estado).animate().fadeIn(delay: 400.ms), _TimerWidget(estado: estado).animate().fadeIn(delay: 400.ms),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -358,16 +359,18 @@ class _InfoChips extends StatelessWidget {
} }
class _GrabacionWidget extends StatelessWidget { class _GrabacionWidget extends StatelessWidget {
final EstadoRadio estado; // Recording state lives in EstadoGrabacion (S4-R2); EstadoRadio no longer
const _GrabacionWidget({required this.estado}); // notifies on recording progress, so this widget watches the new notifier.
const _GrabacionWidget();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final theme = Theme.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 activa = grabacion.activa;
final hayUltimaGrabacion = estado.ultimaGrabacion != null; final hayUltimaGrabacion = estado.ultimoArchivo != null;
return PluriGlassSurface( return PluriGlassSurface(
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(24),
@@ -416,7 +419,7 @@ class _GrabacionWidget extends StatelessWidget {
label: Text(activa ? l10n.stopAction : l10n.recordAction), label: Text(activa ? l10n.stopAction : l10n.recordAction),
onPressed: onPressed:
activa activa
? estado.detenerGrabacion ? estado.detener
: () => _mostrarDialogoGrabacion(context), : () => _mostrarDialogoGrabacion(context),
), ),
if (!activa) if (!activa)
@@ -440,7 +443,8 @@ class _GrabacionWidget extends StatelessWidget {
Future<void> _abrirUltimaGrabacion(BuildContext context) async { Future<void> _abrirUltimaGrabacion(BuildContext context) async {
final messenger = ScaffoldMessenger.of(context); final messenger = ScaffoldMessenger.of(context);
final abierto = await estado.abrirUltimaGrabacion(); final abierto =
await context.read<EstadoGrabacion>().abrirUltimaGrabacion();
if (!context.mounted) return; if (!context.mounted) return;
if (!abierto) { if (!abierto) {
messenger.showSnackBar( messenger.showSnackBar(
@@ -453,7 +457,7 @@ class _GrabacionWidget extends StatelessWidget {
Future<void> _abrirCarpetaGrabaciones(BuildContext context) async { Future<void> _abrirCarpetaGrabaciones(BuildContext context) async {
final messenger = ScaffoldMessenger.of(context); final messenger = ScaffoldMessenger.of(context);
final abierto = await estado.abrirDirectorioGrabacion(); final abierto = await context.read<EstadoGrabacion>().abrirDirectorio();
if (!context.mounted) return; if (!context.mounted) return;
if (!abierto) { if (!abierto) {
messenger.showSnackBar( messenger.showSnackBar(
@@ -467,6 +471,7 @@ class _GrabacionWidget extends StatelessWidget {
} }
void _mostrarDialogoGrabacion(BuildContext context) { void _mostrarDialogoGrabacion(BuildContext context) {
final grabacion = context.read<EstadoGrabacion>();
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
builder: builder:
@@ -495,7 +500,7 @@ class _GrabacionWidget extends StatelessWidget {
), ),
label: Text(AppLocalizations.of(ctx).indefiniteOption), label: Text(AppLocalizations.of(ctx).indefiniteOption),
onPressed: () { onPressed: () {
estado.iniciarGrabacion(); grabacion.iniciar();
Navigator.pop(ctx); Navigator.pop(ctx);
}, },
), ),
@@ -511,7 +516,7 @@ class _GrabacionWidget extends StatelessWidget {
), ),
), ),
onPressed: () { onPressed: () {
estado.iniciarGrabacion(duracion: opcion.duracion); grabacion.iniciar(duracion: opcion.duracion);
Navigator.pop(ctx); Navigator.pop(ctx);
}, },
), ),
@@ -533,6 +538,7 @@ class _GrabacionWidget extends StatelessWidget {
} }
Future<void> _mostrarDuracionPersonalizada(BuildContext context) async { Future<void> _mostrarDuracionPersonalizada(BuildContext context) async {
final grabacion = context.read<EstadoGrabacion>();
final minutosCtrl = TextEditingController(); final minutosCtrl = TextEditingController();
final segundosCtrl = TextEditingController(text: '0'); final segundosCtrl = TextEditingController(text: '0');
final formKey = GlobalKey<FormState>(); final formKey = GlobalKey<FormState>();
@@ -585,7 +591,7 @@ class _GrabacionWidget extends StatelessWidget {
seconds: segundos, seconds: segundos,
); );
if (duracion <= Duration.zero) return; if (duracion <= Duration.zero) return;
estado.iniciarGrabacion(duracion: duracion); grabacion.iniciar(duracion: duracion);
Navigator.pop(ctx); Navigator.pop(ctx);
}, },
child: Text(AppLocalizations.of(ctx).recordAction), child: Text(AppLocalizations.of(ctx).recordAction),
@@ -3,7 +3,7 @@
**Mode**: Strict TDD (test runner: `flutter test`) **Mode**: Strict TDD (test runner: `flutter test`)
**Artifact store**: openspec (Engram unavailable this session) **Artifact store**: openspec (Engram unavailable this session)
**Delivery**: auto-chain, local apply — no commits, no PRs (user commits at own cadence) **Delivery**: auto-chain, local apply — no commits, no PRs (user commits at own cadence)
**Last updated**: 2026-06-11 (Batch 5) **Last updated**: 2026-06-11 (Batch 6)
## Batch log ## Batch log
@@ -14,6 +14,7 @@
| 3 | S3a + S3b — Test seams (statics/prefs/cache/mutex/dirty-guard/bounded set) + audio_session | COMPLETE (Dart-only batch; call-pause on-device verification deferred to user) | 2026-06-11 | | 3 | S3a + S3b — Test seams (statics/prefs/cache/mutex/dirty-guard/bounded set) + audio_session | COMPLETE (Dart-only batch; call-pause on-device verification deferred to user) | 2026-06-11 |
| 4 | S7 — Streaming resilience (buffer config, reconnect state machine, UI wiring) | COMPLETE (Dart-only batch; stream-drop on-device verification deferred to user) | 2026-06-11 | | 4 | S7 — Streaming resilience (buffer config, reconnect state machine, UI wiring) | COMPLETE (Dart-only batch; stream-drop on-device verification deferred to user) | 2026-06-11 |
| 5 | S4a — ServicioExportImport + EstadoEcualizador extraction + compat getters | COMPLETE (Dart-only batch) | 2026-06-11 | | 5 | S4a — ServicioExportImport + EstadoEcualizador extraction + compat getters | COMPLETE (Dart-only batch) | 2026-06-11 |
| 6 | S4b — EstadoGrabacion + EstadoBusqueda + scoped rebuilds + compat-getter removal | COMPLETE (Dart-only batch) | 2026-06-11 |
## Task status (cumulative) ## Task status (cumulative)
@@ -146,9 +147,27 @@
| T-S4a-09 | [x] | `flutter analyze` — No issues found | | T-S4a-09 | [x] | `flutter analyze` — No issues found |
| T-S4a-10 | [x] | `dart format` on 8 touched files (4 reflowed); analyze + suite re-run after format | | T-S4a-10 | [x] | `dart format` on 8 touched files (4 reflowed); analyze + suite re-run after format |
### Slice S4b — EstadoGrabacion + EstadoBusqueda + scoped rebuilds — 13/13 complete
| Task | Status | Notes |
|------|--------|-------|
| T-S4b-01 | [x] | RED: `estado_grabacion_test.dart` — 4 tests (notify on state change, iniciar delegates with current station, no-station → alError without service call, error state → alError) over a controlled ServicioGrabacionRadio fake |
| T-S4b-02 | [x] | RED: `estado_busqueda_test.dart` — 3 tests (notify on buscar, pagination/memory cap MOVED from estado_radio_test, identity-stable `resultados` getter) |
| T-S4b-03 | [x] | RED: `pantalla_inicio_rebuild_test.dart` — EQ preset change does NOT rebuild PantallaInicio (S4-R5-A), `debugPrintRebuildDirtyWidgets` probe + positive control (cargarPopulares DOES rebuild) |
| T-S4b-04 | [x] | GREEN: `lib/estado/estado_grabacion.dart` — owns service, subscription, dir/maxBytes/open actions, `pluriwave/file_actions` channel; `emisoraActual`+`alError` seams; ListenableProvider in app.dart |
| T-S4b-05 | [x] | GREEN: `lib/estado/estado_busqueda.dart` — search + nearby (cercanas) + min-bitrate filter; `ordenListas`/`textos`/`alError` seams; ListenableProvider in app.dart |
| T-S4b-06 | [x] | GREEN: pantalla_inicio — no root watch; selects over identity-memoized getters (NEW `lib/estado/orden_emisoras.dart`: enum + sorter + MemoLista); cercanas/genre sections on EstadoBusqueda |
| T-S4b-07 | [x] | GREEN: pantalla_ajustes 6 watch sites — Grabaciones → watch<EstadoGrabacion>; Timer/Orden/Grupos/Preferida/Emisoras → context.select; _SeccionInfo keeps scoped Consumer |
| T-S4b-08 | [x] | GREEN: pantalla_favoritos → selects; ALSO pantalla_buscar root watch → EstadoBusqueda and pantalla_reproductor `_GrabacionWidget` → EstadoGrabacion (mandatory: EstadoRadio no longer notifies on recording/search) |
| T-S4b-09 | [x] | GREEN: estado_radio.dart — 15 compat members removed (zero `TODO(S4b)` in lib/), recording + search state extracted; 1121 (pre-split) → **753 lines** |
| T-S4b-10 | [x] | Targeted run 8/8 green (RED first: `+0 -3` load failures) |
| T-S4b-11 | [x] | Full suite 110/110 (103 baseline 1 moved test + 8 new) |
| T-S4b-12 | [x] | `flutter analyze` — No issues found |
| T-S4b-13 | [x] | `dart format` on 15 touched files (10 reflowed); analyze + suite re-run after |
### Remaining slices (not started) ### Remaining slices (not started)
S4b, S5, S6, cross-cutting (T-CC-01, T-CC-02) — all pending. S5, S6, cross-cutting (T-CC-01, T-CC-02) — all pending.
## Snooze defect fixes (design audit D1D5 / S1S5) ## Snooze defect fixes (design audit D1D5 / S1S5)
@@ -207,6 +226,16 @@ RED run evidence (Batch 4): `00:00 +0 -2` (both files fail to load). GREEN: targ
RED run evidence (Batch 5): `00:00 +0 -2` (both files fail to load — captured before any lib code). GREEN: targeted `00:00 +4: All tests passed!`; full suite `00:12 +103: All tests passed!` (99 baseline + 4 new); analyze + suite re-run after format. RED run evidence (Batch 5): `00:00 +0 -2` (both files fail to load — captured before any lib code). GREEN: targeted `00:00 +4: All tests passed!`; full suite `00:12 +103: All tests passed!` (99 baseline + 4 new); analyze + suite re-run after format.
### Batch 6 TDD Cycle Evidence (S4b)
| Task | RED (test written first, failing) | GREEN (implementation passes) | REFACTOR |
|------|-----------------------------------|-------------------------------|----------|
| T-S4b-01/T-S4b-04 | Load failure: `estado_grabacion.dart` missing (`+0 -3` run) | EstadoGrabacion created; 4 tests pass | Comment ties callbacks to the S4a seam pattern |
| T-S4b-02/T-S4b-05 | Same RED run: `estado_busqueda.dart` missing | EstadoBusqueda created; 3 tests pass | Pagination test deduplicated out of estado_radio_test |
| T-S4b-03/T-S4b-06..09 | Same RED run: `estado.busqueda` undefined; then first GREEN attempt FAILED honestly (`Expected: true Actual: <false>`) because the `element.dirty` probe cannot observe provider's deferred dependent notification | Probe rewritten over `debugPrintRebuildDirtyWidgets`; EQ change → screen NOT in rebuild log; cargarPopulares control → screen IS in log | Memo identity test added to estado_busqueda_test locking the select-enabler invariant |
RED run evidence (Batch 6): `00:00 +0 -3` (all three files fail to load — captured before any lib code). GREEN: targeted 8/8; full suite `00:11 +110: All tests passed!` (103 baseline 1 moved + 8 new); analyze + suite re-run after format.
## Files changed (Batch 2) ## Files changed (Batch 2)
| File | Action | ~Lines | | File | Action | ~Lines |
@@ -292,6 +321,38 @@ Total Batch 4 diff: ~235 insertions / ~13 deletions in lib (incl. ARB/gen), plus
Total Batch 5 diff: ~455 insertions / ~242 deletions in lib, plus ~137 lines of new tests. Slightly over the ~350-line slice estimate because the EQ method bodies moved (not duplicated) into the new notifier — net lib growth is ~+213. No Kotlin/native files touched. Total Batch 5 diff: ~455 insertions / ~242 deletions in lib, plus ~137 lines of new tests. Slightly over the ~350-line slice estimate because the EQ method bodies moved (not duplicated) into the new notifier — net lib growth is ~+213. No Kotlin/native files touched.
## Files changed (Batch 6)
| File | Action | ~Lines |
|------|--------|--------|
| `lib/estado/orden_emisoras.dart` | Created | +55 (OrdenEmisoras enum moved here + `ordenarEmisoras` + `MemoLista` identity memo; estado_radio re-exports the enum so existing imports keep compiling) |
| `lib/estado/estado_grabacion.dart` | Created | +144 (recording notifier: service ownership, subscription, dir/maxBytes, open-file/dir actions, file_actions channel) |
| `lib/estado/estado_busqueda.dart` | Created | +222 (search notifier: query/filters/pagination, cercanas + geolocation, min-bitrate filter, memoized sorted views) |
| `lib/estado/estado_radio.dart` | Modified | +154/-375 net (recording/search/EQ-compat removed; memoized list getters; creates+disposes the 3 notifiers; custom-station mutations now reassign for memo identity) — **753 lines final (was ~1121 pre-split)** |
| `lib/app.dart` | Modified | +10/-4 (ListenableProviders for EstadoGrabacion + EstadoBusqueda) |
| `lib/pantallas/pantalla_inicio.dart` | Modified | ~+55/-41 (root watch removed; selects + EstadoBusqueda sections) |
| `lib/pantallas/pantalla_buscar.dart` | Modified | ~+30/-27 (root watch → watch<EstadoBusqueda>; renamed members) |
| `lib/pantallas/pantalla_favoritos.dart` | Modified | ~+12/-3 (root watch → selects) |
| `lib/pantallas/pantalla_ajustes.dart` | Modified | ~+50/-32 (Grabaciones → EstadoGrabacion; 5 sections → selects) |
| `lib/pantallas/pantalla_reproductor.dart` | Modified | ~+18/-10 (_GrabacionWidget → watch<EstadoGrabacion>) |
| `test/estado/estado_grabacion_test.dart` | Created | +122 (4 tests) |
| `test/estado/estado_busqueda_test.dart` | Created | +67 (3 tests) |
| `test/pantallas/pantalla_inicio_rebuild_test.dart` | Created | +97 (1 test, S4-R5-A) |
| `test/estado/estado_radio_test.dart` | Modified | EQ call sites → `estado.ecualizador.*`; pagination test moved out |
| `test/pantallas/pantalla_inicio_test.dart` | Modified | `_conProviders` helper mirrors app.dart wiring (3 pump sites) |
Total Batch 6 lib diff: ~386 insertions / ~625 deletions across 9 pre-existing files plus 3 new lib files (+421) and 3 new test files (+286). Net lib growth ≈ +180; EstadoRadio shrank by ~260 lines this batch. No Kotlin/native, .arb or gen/ files touched.
## Deviations from design (Batch 6)
1. **Provider ownership NOT inverted — documented as accepted.** Design 217 allows "pass the shared service instances at construction"; EstadoEcualizador/EstadoGrabacion/EstadoBusqueda need EstadoRadio's services AND callbacks (`emisoraActual`, `alError`, `ordenListas`, `textos`) at construction, so EstadoRadio creates and disposes all three and the ListenableProviders only expose the instances (S4a deviation 2 pattern, now final). Inverting would require lifting ServicioAudio/ServicioRadio creation into app.dart — out of slice budget and blast radius.
2. **NEW `lib/estado/orden_emisoras.dart` (not in task text).** Two reasons: (a) the `OrdenEmisoras` enum is needed by both EstadoRadio and EstadoBusqueda without a circular import (estado_radio re-exports it, so consumers compile unchanged); (b) `MemoLista` — derived-list getters used to return a fresh copy per read, which would make every `context.select` degrade to watch behavior (lists compare by identity). Identity-memoized getters are the enabler that makes S4-R5's "stop rebuilding on buffer events" REAL, not just formal.
3. **EstadoBusqueda also owns the nearby-stations (cercanas) flow** (task text only said query/results/loading). cercanas shares the min-bitrate filter and `radio.buscar` plumbing with search; leaving it in EstadoRadio would have kept a search-state remnant there against S4-R3's intent.
4. **`pantalla_buscar` and `pantalla_reproductor` rewired beyond the task list** (tasks named inicio/ajustes/favoritos). Mandatory, not optional: EstadoRadio no longer notifies on search or recording changes, so any screen still reading them through EstadoRadio would go permanently stale. Buscar now watches EstadoBusqueda; the player's `_GrabacionWidget` watches EstadoGrabacion.
5. **Custom-station mutations reassign the backing list** instead of mutating in place — required so the identity memo (and therefore `select`) sees the change. Behavior identical.
6. **`element.dirty` is NOT a valid rebuild probe with provider** — provider defers dependent notification to the next build phase (`markNeedsNotifyDependents` → inherited element rebuild → dependents marked during build). The widget test uses `debugPrintRebuildDirtyWidgets` log capture with a positive control instead. Worth remembering for future rebuild-scope tests.
7. **`emisorasDisponiblesPreferencia` staleness window (minor, accepted):** the preferred-station dropdown's option list now refreshes when favoritos/custom/populares/tendencias change identity, but a pure search/cercanas update no longer rebuilds the section (EstadoRadio does not notify on those anymore). The options re-derive on the section's next rebuild; preferred-station resolution itself prefers favorites, so impact is cosmetic.
## Deviations from design (Batch 5) ## Deviations from design (Batch 5)
1. **`importar()` returns `Map<String, dynamic>?`, not a `ConfiguracionCompleta` model** (task text suggested one). EstadoRadio's `importarConfig(Map)` is the existing application API with v1/v2 branching and a localized version-guard error; introducing a typed model would force re-validating/re-mapping every section twice in a slice that must stay under budget. The service's contract (graceful null on malformed, version inside the map) covers S4-R4; a typed model can land with S4b/S6 if wanted. 1. **`importar()` returns `Map<String, dynamic>?`, not a `ConfiguracionCompleta` model** (task text suggested one). EstadoRadio's `importarConfig(Map)` is the existing application API with v1/v2 branching and a localized version-guard error; introducing a typed model would force re-validating/re-mapping every section twice in a slice that must stay under budget. The service's contract (graceful null on malformed, version inside the map) covers S4-R4; a typed model can land with S4b/S6 if wanted.
@@ -416,9 +477,27 @@ From tasks.md Section 11 — S1 items still pending from Batch 1, plus new S2 it
2. **EQ controls still live-update (S4-R1):** toggle EQ from the player screen and from Ajustes; chip/switch/preset selector reflect changes immediately (these now rebuild from EstadoEcualizador, not EstadoRadio). 2. **EQ controls still live-update (S4-R1):** toggle EQ from the player screen and from Ajustes; chip/switch/preset selector reflect changes immediately (these now rebuild from EstadoEcualizador, not EstadoRadio).
3. **Per-station preset on playback switch:** play a station with its own preset, switch to one without → main preset re-applies (path now goes through EstadoEcualizador). 3. **Per-station preset on playback switch:** play a station with its own preset, switch to one without → main preset re-applies (path now goes through EstadoEcualizador).
## Verification summary (Batch 6)
- `flutter test`: 110/110 passing (103 baseline 1 pagination test moved to estado_busqueda_test + 8 new across 3 files); re-run after `dart format`
- `flutter analyze`: No issues found (identical to baseline) — used as the safety net for missed call sites after removing the 15 compat members; re-run after format
- `dart format`: applied to all 15 touched Dart files (10 reflowed)
- `rg 'TODO\(S4b\)' lib/`: ZERO occurrences (only historical mentions in tasks.md/apply-progress.md remain)
- EstadoRadio final size: **753 lines** (was ~1121 pre-split, ~1010 after S4a)
- `flutter build`: NOT run (forbidden)
- No Kotlin/native, .arb or gen/ files touched in this batch
### Manual verification items added by Batch 6 (user)
1. **Search screen (S4-R3):** search by name/country/language/quality, infinite scroll, genre chips on home — results and spinners behave as before (now driven by EstadoBusqueda).
2. **Nearby stations (S4-R3):** "Detect" on home requests location and fills the nearby strip; error text when undetectable.
3. **Recording (S4-R2):** start/stop from the player (indefinite, fixed and custom durations), live duration/bytes counter updates, open-folder/open-last-file buttons, recordings settings section (change/restore dir, max size) — all now via EstadoGrabacion.
4. **Scoped rebuilds (S4-R5):** while audio plays/buffers, home/favorites/settings should feel identical (no visual change expected — the win is fewer rebuilds); list reordering in Ajustes still re-sorts home, search results and favorites.
5. **Stop recording on pause/stop/station switch:** unchanged orchestration in EstadoRadio — verify recording stops when playback pauses/stops or station changes.
## Workload / boundary ## Workload / boundary
- Mode: auto-chain local slices (no PRs) - Mode: auto-chain local slices (no PRs)
- Current work units: S1, S2a, S2b, S3a, S3b, S7 (committed, latest 0380bbb), S4a (complete, in working tree) - Current work units: S1, S2a, S2b, S3a, S3b, S7, S4a (committed, latest 0416b30), S4b (complete, in working tree)
- Boundary (Batch 5): starts from the clean post-0380bbb tree; ends with S4a fully checked off, suite green (103/103). Rollback = revert the 6 lib files + delete the 2 new test files (Dart-only; no native edits). - Boundary (Batch 6): starts from the clean post-0416b30 tree; ends with S4b fully checked off, suite green (110/110). Rollback = revert the 9 modified lib/test files + delete the 6 new files (Dart-only; no native edits).
- Next batch: S4b (EstadoGrabacion + EstadoBusqueda + context.select rewiring + REMOVE the 15 `// TODO(S4b)` compat members added here). S5 is also unblocked (depends only on S2b). - Next batch: S5 (design system / a11y / i18n — unblocked since S2b) then S6 (quality gates — now unblocked: depends on S4b + S5).
@@ -329,25 +329,25 @@ Chain strategy: N/A (local apply)
### S4b pre-work: write failing tests ### S4b pre-work: write failing tests
- [ ] **T-S4b-01** [RED] Create `test/estado/estado_grabacion_test.dart`: `ServicioGrabacionRadio` is managed by `EstadoGrabacion`; notifies listeners on recording state change. (S4-R2) **~20 lines.** - [x] **T-S4b-01** [RED] Create `test/estado/estado_grabacion_test.dart`: `ServicioGrabacionRadio` is managed by `EstadoGrabacion`; notifies listeners on recording state change. (S4-R2) **DONE — 4 tests: notify-on-state-change, iniciar delegates with current station, no-station → alError without service call, service error state → alError.**
- [ ] **T-S4b-02** [RED] Create `test/estado/estado_busqueda_test.dart`: search query update notifies `EstadoBusqueda` listeners. (S4-R3) **~15 lines.** - [x] **T-S4b-02** [RED] Create `test/estado/estado_busqueda_test.dart`: search query update notifies `EstadoBusqueda` listeners. (S4-R3) **DONE — 3 tests: notify on buscar, pagination/memory cap (moved from estado_radio_test), identity-stable `resultados` getter (S4-R5 enabler).**
- [ ] **T-S4b-03** [RED] Add widget test: changing EQ preset does NOT rebuild `PantallaInicio` (S4-R5-A). **~20 lines.** - [x] **T-S4b-03** [RED] Add widget test: changing EQ preset does NOT rebuild `PantallaInicio` (S4-R5-A). **DONE — `test/pantallas/pantalla_inicio_rebuild_test.dart` via `debugPrintRebuildDirtyWidgets` log probe (dirty-flag probe is invalid: provider defers dependent notification to the next build phase) + positive control (cargarPopulares DOES rebuild).**
### S4b implementation ### S4b implementation
- [ ] **T-S4b-04** [GREEN] Create `lib/estado/estado_grabacion.dart`: `EstadoGrabacion extends ChangeNotifier` — owns recording state + `_escucharGrabacion` subscription (currently `estado_radio.dart:51, :79`). Register in `MultiProvider`. **Reqs:** S4-R2. **~80 lines.** - [x] **T-S4b-04** [GREEN] Create `lib/estado/estado_grabacion.dart`: `EstadoGrabacion extends ChangeNotifier` — owns recording state + `_escucharGrabacion` subscription (currently `estado_radio.dart:51, :79`). Register in `MultiProvider`. **Reqs:** S4-R2. **DONE — owns ServicioGrabacionRadio, the state subscription, dir/maxBytes/open-file actions and the `pluriwave/file_actions` MethodChannel; `emisoraActual` + `alError` callback seams (mirrors S4a). ListenableProvider in app.dart.**
- [ ] **T-S4b-05** [GREEN] Create `lib/estado/estado_busqueda.dart`: `EstadoBusqueda extends ChangeNotifier` — owns search query, results, loading state. Register in `MultiProvider`. **Reqs:** S4-R3. **~60 lines.** - [x] **T-S4b-05** [GREEN] Create `lib/estado/estado_busqueda.dart`: `EstadoBusqueda extends ChangeNotifier` — owns search query, results, loading state. Register in `MultiProvider`. **Reqs:** S4-R3. **DONE — also owns nearby-stations (cercanas) lookup and min-bitrate filter (they shared search state); `ordenListas`/`textos`/`alError` callback seams. ListenableProvider in app.dart.**
- [ ] **T-S4b-06** [GREEN] Edit `lib/pantallas/pantalla_inicio.dart` (line 43): replace root `context.watch<EstadoRadio>()` with `context.select` / `Consumer` scoped to fields it actually reads. **Reqs:** S4-R5. **~30 lines.** - [x] **T-S4b-06** [GREEN] Edit `lib/pantallas/pantalla_inicio.dart` (line 43): replace root `context.watch<EstadoRadio>()` with `context.select` / `Consumer` scoped to fields it actually reads. **Reqs:** S4-R5. **DONE — selects over identity-memoized getters (NEW `lib/estado/orden_emisoras.dart` MemoLista); cercanas/genre-search sections consume EstadoBusqueda.**
- [ ] **T-S4b-07** [GREEN] Edit `lib/pantallas/pantalla_ajustes.dart` (~6 watch sites): replace each `context.watch<EstadoRadio>()` with scoped `context.select` / `Consumer` for the specific field. **Reqs:** S4-R5. **~40 lines.** - [x] **T-S4b-07** [GREEN] Edit `lib/pantallas/pantalla_ajustes.dart` (~6 watch sites): replace each `context.watch<EstadoRadio>()` with scoped `context.select` / `Consumer` for the specific field. **Reqs:** S4-R5. **DONE — Grabaciones → watch<EstadoGrabacion>; Timer/Orden/Grupos/Preferida/Emisoras → context.select; _SeccionInfo keeps its scoped Consumer.**
- [ ] **T-S4b-08** [GREEN] Edit `lib/pantallas/pantalla_favoritos.dart`: scope the `EstadoRadio` watch. **Reqs:** S4-R5. **~15 lines.** - [x] **T-S4b-08** [GREEN] Edit `lib/pantallas/pantalla_favoritos.dart`: scope the `EstadoRadio` watch. **Reqs:** S4-R5. **DONE — selects listaFavoritos + gruposFavoritos. ALSO: pantalla_buscar root watch → watch<EstadoBusqueda>; pantalla_reproductor `_GrabacionWidget` → watch<EstadoGrabacion> (required: EstadoRadio no longer notifies on recording/search).**
- [ ] **T-S4b-09** [GREEN] Edit `lib/estado/estado_radio.dart`: remove EQ, recording, and search state fields/methods; remove backward-compatible getters added in S4a (they carried `// TODO(S4b): remove getter` comments). **Reqs:** S4-R1, S4-R2, S4-R3. **~80 lines removed.** - [x] **T-S4b-09** [GREEN] Edit `lib/estado/estado_radio.dart`: remove EQ, recording, and search state fields/methods; remove backward-compatible getters added in S4a (they carried `// TODO(S4b): remove getter` comments). **Reqs:** S4-R1, S4-R2, S4-R3. **DONE — all 15 compat members removed (zero TODO(S4b) in lib/); recording + search state/methods extracted; EstadoRadio 1121 (pre-split) → 753 lines, focused on playback/stations/favorites orchestration.**
### S4b verification ### S4b verification
- [ ] **T-S4b-10** Run `flutter test test/estado/estado_grabacion_test.dart test/estado/estado_busqueda_test.dart` plus the rebuild scope test. - [x] **T-S4b-10** Run `flutter test test/estado/estado_grabacion_test.dart test/estado/estado_busqueda_test.dart` plus the rebuild scope test — 8/8 green (RED captured first: `+0 -3` load failures).
- [ ] **T-S4b-11** Run `flutter test` (full suite) — no regressions. - [x] **T-S4b-11** Run `flutter test` (full suite) — 110/110 passing (103 baseline 1 moved pagination test + 8 new), no regressions.
- [ ] **T-S4b-12** Run `flutter analyze`zero errors. - [x] **T-S4b-12** Run `flutter analyze``No issues found!`.
- [ ] **T-S4b-13** Run `dart format lib/estado/estado_grabacion.dart lib/estado/estado_busqueda.dart lib/estado/estado_radio.dart lib/pantallas/pantalla_inicio.dart lib/pantallas/pantalla_ajustes.dart lib/pantallas/pantalla_favoritos.dart`. - [x] **T-S4b-13** Run `dart format` on all 15 touched Dart files (10 reflowed); analyze + suite re-run after format.
### S4b Definition of Done ### S4b Definition of Done
- `flutter test` green. - `flutter test` green.
+67
View File
@@ -0,0 +1,67 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_busqueda.dart';
import '../helpers/fakes.dart';
/// S4-R3: EstadoBusqueda owns search query, results and loading state
/// previously in EstadoRadio.
void main() {
test('actualizar la búsqueda notifica a los listeners', () async {
final busqueda = EstadoBusqueda(
radio: FakeServicioRadio(
busqueda: [emisoraDemo(uuid: 'b-1', nombre: 'Resultado Uno')],
),
);
addTearDown(busqueda.dispose);
var notificaciones = 0;
busqueda.addListener(() => notificaciones++);
await busqueda.buscar(nombre: 'uno');
// At least once for the loading flag and once for the results.
expect(notificaciones, greaterThanOrEqualTo(2));
expect(busqueda.cargando, isFalse);
expect(busqueda.resultados.map((e) => e.uuid), contains('b-1'));
});
test('cargarMas pagina resultados y acota memoria', () async {
final emisoras = List.generate(
70,
(i) => emisoraDemo(uuid: 'page-$i', nombre: 'Page $i'),
);
final busqueda = EstadoBusqueda(
radio: FakeServicioRadio(busqueda: emisoras),
);
addTearDown(busqueda.dispose);
await busqueda.buscar(nombre: 'page');
expect(busqueda.resultados, hasLength(30));
expect(busqueda.hayMas, isTrue);
await busqueda.cargarMas();
expect(busqueda.resultados, hasLength(60));
await busqueda.cargarMas();
expect(busqueda.resultados, hasLength(70));
expect(busqueda.hayMas, isFalse);
});
test(
'resultados conserva identidad entre lecturas sin cambios (S4-R5)',
() async {
final busqueda = EstadoBusqueda(
radio: FakeServicioRadio(
busqueda: [emisoraDemo(uuid: 'b-1', nombre: 'Resultado Uno')],
),
);
addTearDown(busqueda.dispose);
await busqueda.buscar(nombre: 'uno');
// Identity-stable getters let `context.select` skip rebuilds when the
// underlying data did not change.
expect(identical(busqueda.resultados, busqueda.resultados), isTrue);
},
);
}
+124
View File
@@ -0,0 +1,124 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_grabacion.dart';
import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/servicios/servicio_grabacion_radio.dart';
import '../helpers/fakes.dart';
/// S4-R2: EstadoGrabacion owns the recording state previously in EstadoRadio
/// and manages ServicioGrabacionRadio.
void main() {
test('notifica listeners cuando cambia el estado de grabación', () async {
final servicio = _ServicioGrabacionControlado();
final estado = EstadoGrabacion(servicio: servicio);
addTearDown(estado.dispose);
var notificaciones = 0;
estado.addListener(() => notificaciones++);
servicio.emitir(
EstadoGrabacionRadio(
tipo: EstadoGrabacionRadioTipo.grabando,
emisora: emisoraDemo(uuid: 'rec-1', nombre: 'Grabable'),
inicio: DateTime(2026, 6, 11, 10),
),
);
await Future<void>.delayed(Duration.zero);
expect(notificaciones, 1);
expect(estado.activa, isTrue);
expect(estado.estado.tipo, EstadoGrabacionRadioTipo.grabando);
});
test('iniciar delega en el servicio con la emisora actual', () async {
final servicio = _ServicioGrabacionControlado();
final emisora = emisoraDemo(uuid: 'rec-2', nombre: 'Actual');
final estado = EstadoGrabacion(
servicio: servicio,
emisoraActual: () => emisora,
);
addTearDown(estado.dispose);
await estado.iniciar(duracion: const Duration(minutes: 1));
expect(servicio.inicios, 1);
expect(servicio.emisoraIniciada?.uuid, 'rec-2');
expect(servicio.duracionIniciada, const Duration(minutes: 1));
});
test(
'iniciar sin emisora actual reporta error y no llama al servicio',
() async {
final servicio = _ServicioGrabacionControlado();
final errores = <String>[];
final estado = EstadoGrabacion(
servicio: servicio,
emisoraActual: () => null,
alError: errores.add,
);
addTearDown(estado.dispose);
await estado.iniciar();
expect(servicio.inicios, 0);
expect(errores, hasLength(1));
},
);
test('un estado de error del servicio se reporta vía alError', () async {
final servicio = _ServicioGrabacionControlado();
final errores = <String>[];
final estado = EstadoGrabacion(servicio: servicio, alError: errores.add);
addTearDown(estado.dispose);
servicio.emitir(
const EstadoGrabacionRadio(
tipo: EstadoGrabacionRadioTipo.error,
error: 'HTTP 500',
),
);
await Future<void>.delayed(Duration.zero);
expect(errores, hasLength(1));
expect(errores.single, contains('HTTP 500'));
});
}
class _ServicioGrabacionControlado extends ServicioGrabacionRadio {
final _controller = StreamController<EstadoGrabacionRadio>.broadcast();
EstadoGrabacionRadio _estadoActual = const EstadoGrabacionRadio.inactiva();
int inicios = 0;
Emisora? emisoraIniciada;
Duration? duracionIniciada;
@override
EstadoGrabacionRadio get estado => _estadoActual;
@override
Stream<EstadoGrabacionRadio> get estadoStream => _controller.stream;
@override
Future<void> inicializar() async {}
@override
Future<void> iniciar(
Emisora emisora, {
Duration? duracion,
String? directorio,
}) async {
inicios++;
emisoraIniciada = emisora;
duracionIniciada = duracion;
}
void emitir(EstadoGrabacionRadio estado) {
_estadoActual = estado;
_controller.add(estado);
}
@override
Future<void> dispose() => _controller.close();
}
+23 -53
View File
@@ -60,7 +60,7 @@ void main() {
await estado.inicializar(); await estado.inicializar();
await estado.reproducir(emisora); await estado.reproducir(emisora);
expect(estado.presetEcualizador, principal); expect(estado.ecualizador.presetActual, principal);
expect(audio.presetsAplicados.first, principal); expect(audio.presetsAplicados.first, principal);
expect(audio.presetsAplicados.last, principal); expect(audio.presetsAplicados.last, principal);
}, },
@@ -85,11 +85,11 @@ void main() {
await estado.inicializar(); await estado.inicializar();
expect(estado.ecualizadorDisponible, isFalse); expect(estado.ecualizador.disponible, isFalse);
expect(estado.presetEcualizador, principal); expect(estado.ecualizador.presetActual, principal);
expect(estado.presetPrincipalEcualizador, principal); expect(estado.ecualizador.presetPrincipal, principal);
expect( expect(
estado.presetEcualizadorPorEmisora('fav-1'), estado.ecualizador.presetPorEmisora('fav-1'),
PresetEcualizador.rock, PresetEcualizador.rock,
); );
}, },
@@ -151,15 +151,15 @@ void main() {
await estado.inicializar(); await estado.inicializar();
await estado.cargarFavoritos(); await estado.cargarFavoritos();
await estado.guardarPresetEcualizadorPorEmisora(emisora.uuid, propio); await estado.ecualizador.guardarPresetPorEmisora(emisora.uuid, propio);
await estado.reproducir(emisora); await estado.reproducir(emisora);
expect(estado.presetEcualizador, propio); expect(estado.ecualizador.presetActual, propio);
expect(audio.presetsAplicados.last, propio); expect(audio.presetsAplicados.last, propio);
await estado.deshabilitarPresetEcualizadorPorEmisora(emisora.uuid); await estado.ecualizador.deshabilitarPresetPorEmisora(emisora.uuid);
await estado.reproducir(emisora); await estado.reproducir(emisora);
expect(estado.presetEcualizador, principal); expect(estado.ecualizador.presetActual, principal);
expect(audio.presetsAplicados.last, principal); expect(audio.presetsAplicados.last, principal);
}, },
); );
@@ -186,8 +186,8 @@ void main() {
await estado.cargarFavoritos(); await estado.cargarFavoritos();
await estado.reproducir(emisora); await estado.reproducir(emisora);
expect(estado.tienePresetEcualizadorPorEmisora(emisora.uuid), isFalse); expect(estado.ecualizador.tienePresetPorEmisora(emisora.uuid), isFalse);
expect(estado.presetEcualizador, principal); expect(estado.ecualizador.presetActual, principal);
expect(audio.presetsAplicados.last, principal); expect(audio.presetsAplicados.last, principal);
}, },
); );
@@ -207,15 +207,15 @@ void main() {
); );
await estado.inicializar(); await estado.inicializar();
expect(estado.ecualizadorActivo, isTrue); expect(estado.ecualizador.activo, isTrue);
await estado.cambiarEcualizadorActivo(false); await estado.ecualizador.cambiarActivo(false);
expect(estado.ecualizadorActivo, isFalse); expect(estado.ecualizador.activo, isFalse);
expect(servicioEcualizador.config.activo, isFalse); expect(servicioEcualizador.config.activo, isFalse);
expect(audio.cambiosEcualizadorActivo.last, isFalse); expect(audio.cambiosEcualizadorActivo.last, isFalse);
await estado.cambiarEcualizadorActivo(true); await estado.ecualizador.cambiarActivo(true);
expect(estado.ecualizadorActivo, isTrue); expect(estado.ecualizador.activo, isTrue);
expect(servicioEcualizador.config.activo, isTrue); expect(servicioEcualizador.config.activo, isTrue);
expect(audio.cambiosEcualizadorActivo.last, isTrue); expect(audio.cambiosEcualizadorActivo.last, isTrue);
}, },
@@ -285,11 +285,11 @@ void main() {
); );
await estado.inicializar(); await estado.inicializar();
await estado.guardarPresetEcualizadorPorEmisora( await estado.ecualizador.guardarPresetPorEmisora(
primera.uuid, primera.uuid,
PresetEcualizador.rock, PresetEcualizador.rock,
); );
await estado.guardarPresetEcualizadorPorEmisora( await estado.ecualizador.guardarPresetPorEmisora(
segunda.uuid, segunda.uuid,
PresetEcualizador.jazz, PresetEcualizador.jazz,
); );
@@ -301,7 +301,7 @@ void main() {
audio.completar(primera.uuid); audio.completar(primera.uuid);
await primeraFuture; await primeraFuture;
expect(estado.presetEcualizador, PresetEcualizador.jazz); expect(estado.ecualizador.presetActual, PresetEcualizador.jazz);
expect(radio.ultimoUuidClick, segunda.uuid); expect(radio.ultimoUuidClick, segunda.uuid);
}, },
); );
@@ -319,32 +319,8 @@ void main() {
expect(lista.map((e) => e.orden).toList(), equals([0, 1, 2])); expect(lista.map((e) => e.orden).toList(), equals([0, 1, 2]));
}); });
test('cargarMasBusqueda pagina resultados y acota memoria', () async { // The search pagination test moved to test/estado/estado_busqueda_test.dart
final emisoras = List.generate( // (S4-R3: search state extracted to EstadoBusqueda).
70,
(i) => emisoraDemo(uuid: 'page-$i', nombre: 'Page $i'),
);
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(busqueda: emisoras),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
await estado.inicializar();
await estado.buscar(nombre: 'page');
expect(estado.resultadosBusqueda, hasLength(30));
expect(estado.hayMasBusqueda, isTrue);
await estado.cargarMasBusqueda();
expect(estado.resultadosBusqueda, hasLength(60));
await estado.cargarMasBusqueda();
expect(estado.resultadosBusqueda, hasLength(70));
expect(estado.hayMasBusqueda, isFalse);
});
test('toggleFavorito refresca lista global y evita estado stale', () async { test('toggleFavorito refresca lista global y evita estado stale', () async {
final favoritos = FakeServicioFavoritos(); final favoritos = FakeServicioFavoritos();
@@ -388,16 +364,10 @@ void main() {
final grupo = estado.gruposFavoritos.last; final grupo = estado.gruposFavoritos.last;
await estado.asignarGrupoFavorito(emisora.uuid, grupo.id); await estado.asignarGrupoFavorito(emisora.uuid, grupo.id);
expect( expect(estado.listaFavoritos.first.grupoFavoritosId, grupo.id);
estado.listaFavoritos.first.grupoFavoritosId,
grupo.id,
);
await estado.eliminarGrupoFavoritos(grupo.id); await estado.eliminarGrupoFavoritos(grupo.id);
expect( expect(estado.listaFavoritos.first.grupoFavoritosId, 'sin_asignar');
estado.listaFavoritos.first.grupoFavoritosId,
'sin_asignar',
);
}); });
}); });
} }
@@ -0,0 +1,100 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_busqueda.dart';
import 'package:pluriwave/estado/estado_ecualizador.dart';
import 'package:pluriwave/estado/estado_grabacion.dart';
import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/modelos/preset_ecualizador.dart';
import 'package:pluriwave/pantallas/pantalla_inicio.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/fakes.dart';
/// S4-R5-A: changing the EQ preset must NOT rebuild PantallaInicio.
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
testWidgets('cambiar el preset de EQ no marca PantallaInicio para rebuild', (
tester,
) async {
tester.view.physicalSize = const Size(1440, 3200);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final radio = FakeServicioRadio(
populares: [emisoraDemo(uuid: 'api-1', nombre: 'API Uno')],
popularesPorLlamada: [
[emisoraDemo(uuid: 'api-1', nombre: 'API Uno')],
[emisoraDemo(uuid: 'api-2', nombre: 'API Dos')],
],
);
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: radio,
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom:
() async => File(
'${Directory.current.path}/test/fixtures/emisoras_custom_vacio.json',
),
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
await tester.runAsync(estado.inicializar);
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<EstadoRadio>.value(value: estado),
ListenableProvider<EstadoEcualizador>.value(
value: estado.ecualizador,
),
ListenableProvider<EstadoBusqueda>.value(value: estado.busqueda),
ListenableProvider<EstadoGrabacion>.value(value: estado.grabacion),
],
child: MaterialApp(
locale: const Locale('es'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: PantallaInicio()),
),
),
);
await tester.pumpAndSettle(const Duration(milliseconds: 100));
// Provider defers dependent notification to the next build phase, so a
// dirty-flag probe cannot observe it synchronously. Instead, log every
// element rebuilt per frame and look for the screen in that log.
final registro = <String>[];
final debugPrintOriginal = debugPrint;
debugPrintRebuildDirtyWidgets = true;
debugPrint = (String? message, {int? wrapWidth}) {
registro.add(message ?? '');
};
addTearDown(() {
debugPrintRebuildDirtyWidgets = false;
debugPrint = debugPrintOriginal;
});
// EQ preset change: a different notifier must NOT rebuild the screen.
await estado.ecualizador.cambiarPresetPrincipal(PresetEcualizador.rock);
await tester.pump();
expect(registro.any((linea) => linea.contains('PantallaInicio')), isFalse);
// Probe control: a real data change DOES rebuild the screen.
registro.clear();
await tester.runAsync(estado.cargarPopulares);
await tester.pump();
expect(registro.any((linea) => linea.contains('PantallaInicio')), isTrue);
debugPrintRebuildDirtyWidgets = false;
debugPrint = debugPrintOriginal;
await tester.pumpAndSettle(const Duration(milliseconds: 100));
});
}
+21 -16
View File
@@ -3,6 +3,9 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_busqueda.dart';
import 'package:pluriwave/estado/estado_ecualizador.dart';
import 'package:pluriwave/estado/estado_grabacion.dart';
import 'package:pluriwave/estado/estado_radio.dart'; import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart'; import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/pantallas/pantalla_favoritos.dart'; import 'package:pluriwave/pantallas/pantalla_favoritos.dart';
@@ -41,10 +44,7 @@ void main() {
await tester.runAsync(estado.inicializar); await tester.runAsync(estado.inicializar);
await tester.pumpWidget( await tester.pumpWidget(
ChangeNotifierProvider<EstadoRadio>.value( _conProviders(estado, _testApp(const PantallaInicio())),
value: estado,
child: _testApp(const PantallaInicio()),
),
); );
await _pumpStableFrame(tester); await _pumpStableFrame(tester);
@@ -109,10 +109,7 @@ void main() {
await tester.runAsync(estado.inicializar); await tester.runAsync(estado.inicializar);
await tester.pumpWidget( await tester.pumpWidget(
ChangeNotifierProvider<EstadoRadio>.value( _conProviders(estado, _testApp(const PantallaInicio())),
value: estado,
child: _testApp(const PantallaInicio()),
),
); );
await _pumpStableFrame(tester); await _pumpStableFrame(tester);
@@ -153,10 +150,7 @@ void main() {
await tester.runAsync(estado.inicializar); await tester.runAsync(estado.inicializar);
await tester.pumpWidget( await tester.pumpWidget(
ChangeNotifierProvider<EstadoRadio>.value( _conProviders(estado, _testApp(const PantallaInicio())),
value: estado,
child: _testApp(const PantallaInicio()),
),
); );
await _pumpStableFrame(tester); await _pumpStableFrame(tester);
@@ -176,10 +170,7 @@ void main() {
await _pumpStableFrame(tester); await _pumpStableFrame(tester);
await tester.pumpWidget( await tester.pumpWidget(
ChangeNotifierProvider<EstadoRadio>.value( _conProviders(estado, _testApp(const PantallaFavoritos())),
value: estado,
child: _testApp(const PantallaFavoritos()),
),
); );
await _pumpStableFrame(tester); await _pumpStableFrame(tester);
@@ -188,6 +179,20 @@ void main() {
}); });
} }
/// Mirrors the app.dart wiring: EstadoRadio owns the domain notifiers and
/// the providers only expose the instances (no dispose callbacks).
Widget _conProviders(EstadoRadio estado, Widget child) {
return MultiProvider(
providers: [
ChangeNotifierProvider<EstadoRadio>.value(value: estado),
ListenableProvider<EstadoEcualizador>.value(value: estado.ecualizador),
ListenableProvider<EstadoGrabacion>.value(value: estado.grabacion),
ListenableProvider<EstadoBusqueda>.value(value: estado.busqueda),
],
child: child,
);
}
Widget _testApp(Widget body) { Widget _testApp(Widget body) {
return MaterialApp( return MaterialApp(
locale: const Locale('es'), locale: const Locale('es'),