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:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'estado/estado_busqueda.dart';
import 'estado/estado_ecualizador.dart';
import 'estado/estado_grabacion.dart';
import 'estado/estado_radio.dart';
import 'estado/estado_alarmas.dart';
import 'estado/estado_idioma.dart';
@@ -36,12 +38,19 @@ class PluriWaveApp extends StatelessWidget {
return MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => EstadoRadio(prefs: prefs)),
// EQ notifier (S4-R1). Created and disposed by EstadoRadio during
// the S4 transition; this provider only exposes the instance, so it
// declares no dispose callback.
// Domain notifiers (S4-R1/R2/R3). Created and disposed by EstadoRadio
// (they need its services and callbacks at construction); these
// providers only expose the instances, so they declare no dispose
// callback.
ListenableProvider<EstadoEcualizador>(
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: (_) => 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 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
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:shared_preferences/shared_preferences.dart';
import 'package:url_launcher/url_launcher.dart';
import '../l10n/display_names.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/emisora.dart';
import '../modelos/grupo_favoritos.dart';
import '../modelos/preset_ecualizador.dart';
import 'estado_busqueda.dart';
import 'estado_ecualizador.dart';
import 'estado_grabacion.dart';
import 'orden_emisoras.dart';
import '../servicios/servicio_audio.dart';
import '../servicios/servicio_ecualizador.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_timer.dart';
enum OrdenEmisoras { nombre, calidad }
export 'orden_emisoras.dart' show OrdenEmisoras;
/// 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 {
static const MethodChannel _fileActionsChannel = MethodChannel(
'pluriwave/file_actions',
);
EstadoRadio({
ServicioAudio? audio,
ServicioFavoritos? favoritos,
@@ -47,7 +47,6 @@ class EstadoRadio extends ChangeNotifier {
radio = radio ?? ServicioRadio(),
servicioEcualizador =
servicioEcualizador ?? ServicioEcualizador(prefs: prefs),
grabacion = servicioGrabacion ?? ServicioGrabacionRadio(prefs: prefs),
_prefs = prefs,
_resolverArchivoCustom = resolverArchivoCustom {
ecualizador = EstadoEcualizador(
@@ -55,9 +54,19 @@ class EstadoRadio extends ChangeNotifier {
servicio: this.servicioEcualizador,
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);
_escucharErroresReproduccion();
_escucharGrabacion();
if (iniciarAutomaticamente) {
_initFuture = _init();
}
@@ -68,11 +77,12 @@ class EstadoRadio extends ChangeNotifier {
final ServicioRadio radio;
final ServicioEcualizador servicioEcualizador;
/// EQ state extracted to its own notifier (S4-R1). Owned (and disposed)
/// by EstadoRadio during the S4 transition; exposed app-wide through a
/// ListenableProvider in app.dart.
/// Domain notifiers extracted from this class (S4). Created and disposed
/// here (they need EstadoRadio's services and callbacks at construction);
/// exposed app-wide through ListenableProviders in app.dart.
late final EstadoEcualizador ecualizador;
final ServicioGrabacionRadio grabacion;
late final EstadoGrabacion grabacion;
late final EstadoBusqueda busqueda;
static const ServicioExportImport _exportImport = ServicioExportImport();
final SharedPreferences? _prefs;
final Future<File> Function()? _resolverArchivoCustom;
@@ -99,7 +109,6 @@ class EstadoRadio extends ChangeNotifier {
late final ServicioTimer timer;
StreamSubscription<EstadoReproduccion>? _suscripcionEstadoAudio;
StreamSubscription<EstadoGrabacionRadio>? _suscripcionGrabacion;
Future<void>? _initFuture;
int _revisionReproduccion = 0;
Emisora? _emisoraSeleccionada;
@@ -112,26 +121,23 @@ class EstadoRadio extends ChangeNotifier {
List<Emisora> _populares = [];
List<Emisora> _tendencias = [];
List<Emisora> _resultadosBusqueda = [];
List<Emisora> _emisorasCercanas = [];
List<Emisora> _listaFavoritos = [];
List<GrupoFavoritos> _gruposFavoritos = [];
List<Emisora> _emisorasCustom = [];
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;
// 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 _keyOrdenListas = 'orden_listas_emisoras_v1';
static const _keyTimerSuenoPresets = 'timer_sueno_presets_segundos_v1';
@@ -151,38 +157,35 @@ class EstadoRadio extends ChangeNotifier {
);
OrdenEmisoras _ordenListas = OrdenEmisoras.calidad;
List<Emisora> get populares => _ordenarEmisoras(_populares);
List<Emisora> get tendencias => _ordenarEmisoras(_tendencias);
List<Emisora> get resultadosBusqueda => _ordenarEmisoras(_resultadosBusqueda);
List<Emisora> get emisorasCercanas => _ordenarEmisoras(_emisorasCercanas);
List<Emisora> get listaFavoritos => _ordenarEmisoras(_listaFavoritos);
List<GrupoFavoritos> get gruposFavoritos =>
List.unmodifiable(_gruposFavoritos);
List<Emisora> get emisorasCustom => _ordenarEmisoras(_emisorasCustom);
List<Emisora> get populares => _memoPopulares.obtener([
_populares,
_ordenListas,
], () => ordenarEmisoras(_populares, _ordenListas));
List<Emisora> get tendencias => _memoTendencias.obtener([
_tendencias,
_ordenListas,
], () => 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 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;
Emisora? get emisoraActual => _emisoraSeleccionada ?? audio.emisoraActual;
Emisora? get emisoraPreferida => _resolverEmisoraPreferida();
String? get emisoraPreferidaUuid => emisoraPreferida?.uuid;
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;
List<int> get timerSuenoPresetsSegundos =>
List<int>.unmodifiable(_timerSuenoPresetsSegundos);
List<int> get timerSuenoPresetsSegundos => _memoTimerPresets.obtener([
_timerSuenoPresetsSegundos,
], () => List<int>.unmodifiable(_timerSuenoPresetsSegundos));
bool get emisoraActualEsFavorita {
final actual = emisoraActual;
@@ -190,50 +193,51 @@ class EstadoRadio extends ChangeNotifier {
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.
List<Emisora> get emisorasInicio {
final mapa = <String, Emisora>{};
for (final emisora in _emisorasCustom) {
mapa[emisora.uuid] = emisora;
}
for (final emisora in _populares) {
mapa.putIfAbsent(emisora.uuid, () => emisora);
}
return mapa.values.toList();
}
List<Emisora> get emisorasInicio =>
_memoInicio.obtener([_emisorasCustom, _populares], () {
final mapa = <String, Emisora>{};
for (final emisora in _emisorasCustom) {
mapa[emisora.uuid] = emisora;
}
for (final emisora in _populares) {
mapa.putIfAbsent(emisora.uuid, () => emisora);
}
return mapa.values.toList();
});
List<Emisora> get emisorasDisponiblesPreferencia {
final mapa = <String, Emisora>{};
for (final emisora in _listaFavoritos) {
mapa[emisora.uuid] = emisora;
}
for (final emisora in _emisorasCustom) {
mapa.putIfAbsent(emisora.uuid, () => emisora);
}
for (final emisora in _populares) {
mapa.putIfAbsent(emisora.uuid, () => emisora);
}
for (final emisora in _tendencias) {
mapa.putIfAbsent(emisora.uuid, () => emisora);
}
for (final emisora in _resultadosBusqueda) {
mapa.putIfAbsent(emisora.uuid, () => emisora);
}
for (final emisora in _emisorasCercanas) {
mapa.putIfAbsent(emisora.uuid, () => emisora);
}
return mapa.values.toList();
}
List<Emisora> get emisorasDisponiblesPreferencia => _memoDisponibles.obtener(
[
_listaFavoritos,
_emisorasCustom,
_populares,
_tendencias,
busqueda.resultados,
busqueda.cercanas,
],
() {
final mapa = <String, Emisora>{};
for (final emisora in _listaFavoritos) {
mapa[emisora.uuid] = emisora;
}
for (final emisora in _emisorasCustom) {
mapa.putIfAbsent(emisora.uuid, () => emisora);
}
for (final emisora in _populares) {
mapa.putIfAbsent(emisora.uuid, () => emisora);
}
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() {
_initFuture ??= _init();
@@ -264,23 +268,13 @@ class EstadoRadio extends ChangeNotifier {
if ((estado == EstadoReproduccion.detenido ||
estado == EstadoReproduccion.pausado ||
estado == EstadoReproduccion.error) &&
grabacion.estado.activa) {
grabacion.activa) {
unawaited(grabacion.detener());
}
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 {
_cargandoPopulares = true;
_errorCarga = null;
@@ -390,6 +384,8 @@ class EstadoRadio extends ChangeNotifier {
_ordenListas = orden;
final prefs = await _resolverPrefs();
await prefs.setString(_keyOrdenListas, orden.name);
// Search owns its own listeners (S4-R3) but sorts with this preference.
busqueda.notificarCambioOrden();
notifyListeners();
}
@@ -422,176 +418,9 @@ class EstadoRadio extends ChangeNotifier {
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 {
final revision = ++_revisionReproduccion;
if (grabacion.estado.activa) {
if (grabacion.activa) {
await grabacion.detener();
}
_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 {
if (grabacion.estado.activa) {
if (grabacion.activa) {
await grabacion.detener();
}
await audio.detener();
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 {
if (audio.estaSonando && grabacion.estado.activa) {
if (audio.estaSonando && grabacion.activa) {
await grabacion.detener();
}
await audio.togglePlay();
@@ -709,7 +471,7 @@ class EstadoRadio extends ChangeNotifier {
Future<bool> toggleFavorito(Emisora emisora) async {
final esFav = await favoritos.toggleFavorito(emisora);
if (!esFav) {
await deshabilitarPresetEcualizadorPorEmisora(
await ecualizador.deshabilitarPresetPorEmisora(
emisora.uuid,
notificar: false,
);
@@ -720,68 +482,6 @@ class EstadoRadio extends ChangeNotifier {
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 ───────────────────────────────────────────────
Future<File> _archivoCustom() async {
@@ -820,8 +520,11 @@ class EstadoRadio extends ChangeNotifier {
}
Future<void> agregarEmisoraCustom(Emisora emisora) async {
_emisorasCustom.removeWhere((e) => e.uuid == emisora.uuid);
_emisorasCustom.add(emisora);
// Reassign (not mutate) so identity-memoized views refresh (S4-R5).
_emisorasCustom = [
..._emisorasCustom.where((e) => e.uuid != emisora.uuid),
emisora,
];
await _guardarEmisorasCustom();
notifyListeners();
}
@@ -831,7 +534,7 @@ class EstadoRadio extends ChangeNotifier {
agregarEmisoraCustom(emisora);
Future<void> eliminarEmisoraCustom(String uuid) async {
_emisorasCustom.removeWhere((e) => e.uuid == uuid);
_emisorasCustom = _emisorasCustom.where((e) => e.uuid != uuid).toList();
await _guardarEmisorasCustom();
notifyListeners();
}
@@ -1039,11 +742,11 @@ class EstadoRadio extends ChangeNotifier {
@override
void dispose() {
_suscripcionEstadoAudio?.cancel();
_suscripcionGrabacion?.cancel();
_errorController.close();
ecualizador.dispose();
busqueda.dispose();
grabacion.dispose();
audio.dispose();
unawaited(grabacion.dispose());
timer.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 '../estado/estado_ecualizador.dart';
import '../estado/estado_grabacion.dart';
import '../estado/estado_idioma.dart';
import '../estado/estado_radio.dart';
import '../l10n/display_names.dart';
@@ -85,7 +86,7 @@ class _SeccionGrabaciones extends StatelessWidget {
const _SeccionGrabaciones();
Future<void> _seleccionarRuta(BuildContext context) async {
final estado = context.read<EstadoRadio>();
final estado = context.read<EstadoGrabacion>();
final messenger = ScaffoldMessenger.of(context);
final l10n = AppLocalizations.of(context);
final ruta = await FilePicker.platform.getDirectoryPath(
@@ -93,7 +94,7 @@ class _SeccionGrabaciones extends StatelessWidget {
);
if (ruta == null) return;
try {
await estado.cambiarDirectorioGrabacion(ruta);
await estado.cambiarDirectorio(ruta);
if (!context.mounted) return;
messenger.showSnackBar(
SnackBar(content: Text(l10n.recordingsPathUpdated)),
@@ -107,10 +108,10 @@ class _SeccionGrabaciones extends StatelessWidget {
}
Future<void> _restaurarRuta(BuildContext context) async {
final estado = context.read<EstadoRadio>();
final estado = context.read<EstadoGrabacion>();
final messenger = ScaffoldMessenger.of(context);
final l10n = AppLocalizations.of(context);
await estado.restaurarDirectorioGrabacion();
await estado.restaurarDirectorio();
if (!context.mounted) return;
messenger.showSnackBar(
SnackBar(content: Text(l10n.recordingsDefaultFolderRestored)),
@@ -118,11 +119,11 @@ class _SeccionGrabaciones extends StatelessWidget {
}
Future<void> _abrirCarpeta(BuildContext context) async {
final estado = context.read<EstadoRadio>();
final estado = context.read<EstadoGrabacion>();
final messenger = ScaffoldMessenger.of(context);
final l10n = AppLocalizations.of(context);
try {
final abierto = await estado.abrirDirectorioGrabacion();
final abierto = await estado.abrirDirectorio();
if (!context.mounted) return;
if (!abierto) {
messenger.showSnackBar(
@@ -138,9 +139,9 @@ class _SeccionGrabaciones extends StatelessWidget {
}
Future<void> _editarTamanoMaximo(BuildContext context) async {
final estado = context.read<EstadoRadio>();
final estado = context.read<EstadoGrabacion>();
final l10n = AppLocalizations.of(context);
final actualMb = _bytesAMegabytes(estado.maxBytesGrabacion);
final actualMb = _bytesAMegabytes(estado.maxBytes);
final controller = TextEditingController(text: actualMb.toString());
final nuevoMb = await showModalBottomSheet<int>(
@@ -186,7 +187,7 @@ class _SeccionGrabaciones extends StatelessWidget {
);
controller.dispose();
if (nuevoMb == null || !context.mounted) return;
await estado.cambiarMaxBytesGrabacion(nuevoMb * 1024 * 1024);
await estado.cambiarMaxBytes(nuevoMb * 1024 * 1024);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.recordingsMaxSizeSaved(nuevoMb))),
@@ -198,7 +199,9 @@ class _SeccionGrabaciones extends StatelessWidget {
@override
Widget build(BuildContext context) {
final estado = context.watch<EstadoRadio>();
// Recording state lives in EstadoGrabacion (S4-R2): this section only
// rebuilds on recording changes, never on playback notifications.
final estado = context.watch<EstadoGrabacion>();
final l10n = AppLocalizations.of(context);
return PluriGlassSurface(
@@ -216,7 +219,7 @@ class _SeccionGrabaciones extends StatelessWidget {
],
),
FutureBuilder<String>(
future: estado.directorioGrabacionEfectivo(),
future: estado.directorioEfectivo(),
builder:
(ctx, snap) => ListTile(
contentPadding: EdgeInsets.zero,
@@ -256,9 +259,7 @@ class _SeccionGrabaciones extends StatelessWidget {
leading: const Icon(Icons.sd_storage_rounded),
title: Text(l10n.recordingsMaxSizeTitle),
subtitle: Text(
l10n.recordingsMaxSizeSubtitle(
_bytesAMegabytes(estado.maxBytesGrabacion),
),
l10n.recordingsMaxSizeSubtitle(_bytesAMegabytes(estado.maxBytes)),
),
onTap: () => _editarTamanoMaximo(context),
),
@@ -301,8 +302,10 @@ class _SeccionTimerSueno extends StatelessWidget {
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final estado = context.watch<EstadoRadio>();
final presets = estado.timerSuenoPresetsSegundos;
// S4-R5: scoped select — rebuilds only when the presets list changes.
final presets = context.select<EstadoRadio, List<int>>(
(e) => e.timerSuenoPresetsSegundos,
);
return PluriGlassSurface(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -654,7 +657,10 @@ class _SeccionOrdenListas extends StatelessWidget {
@override
Widget build(BuildContext context) {
final estado = context.watch<EstadoRadio>();
// S4-R5: scoped select — rebuilds only when the ordering changes.
final orden = context.select<EstadoRadio, OrdenEmisoras>(
(e) => e.ordenListas,
);
final l10n = AppLocalizations.of(context);
return PluriGlassSurface(
child: Column(
@@ -684,9 +690,9 @@ class _SeccionOrdenListas extends StatelessWidget {
label: Text(l10n.stationOrderByQuality),
),
],
selected: {estado.ordenListas},
selected: {orden},
onSelectionChanged: (value) {
estado.cambiarOrdenListas(value.first);
context.read<EstadoRadio>().cambiarOrdenListas(value.first);
},
),
const SizedBox(height: 8),
@@ -790,9 +796,11 @@ class _SeccionGruposFavoritos extends StatelessWidget {
@override
Widget build(BuildContext context) {
final estado = context.watch<EstadoRadio>();
final l10n = AppLocalizations.of(context);
final grupos = estado.gruposFavoritos;
// S4-R5: scoped select — rebuilds only when the groups list changes.
final grupos = context.select<EstadoRadio, List<GrupoFavoritos>>(
(e) => e.gruposFavoritos,
);
return PluriGlassSurface(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -858,11 +866,18 @@ class _SeccionEmisoraPreferida extends StatelessWidget {
@override
Widget build(BuildContext context) {
final estado = context.watch<EstadoRadio>();
final l10n = AppLocalizations.of(context);
final favoritas = estado.listaFavoritos;
final preferida = estado.emisoraPreferida;
final opciones = _opciones(estado, preferida);
// S4-R5: scoped selects over identity-memoized getters.
final favoritas = context.select<EstadoRadio, List<Emisora>>(
(e) => e.listaFavoritos,
);
final disponibles = context.select<EstadoRadio, List<Emisora>>(
(e) => e.emisorasDisponiblesPreferencia,
);
final preferida = context.select<EstadoRadio, Emisora?>(
(e) => e.emisoraPreferida,
);
final opciones = _opciones(favoritas, disponibles, preferida);
return PluriGlassSurface(
child: Column(
@@ -947,11 +962,12 @@ class _SeccionEmisoraPreferida extends StatelessWidget {
);
}
List<Emisora> _opciones(EstadoRadio estado, Emisora? preferida) {
final base =
estado.listaFavoritos.isNotEmpty
? estado.listaFavoritos
: estado.emisorasDisponiblesPreferencia;
List<Emisora> _opciones(
List<Emisora> favoritas,
List<Emisora> disponibles,
Emisora? preferida,
) {
final base = favoritas.isNotEmpty ? favoritas : disponibles;
final mapa = <String, Emisora>{
for (final emisora in base) emisora.uuid: emisora,
};
@@ -967,8 +983,10 @@ class _SeccionEmisoras extends StatelessWidget {
@override
Widget build(BuildContext context) {
final estado = context.watch<EstadoRadio>();
final custom = estado.emisorasCustom;
// S4-R5: scoped select — rebuilds only when the custom list changes.
final custom = context.select<EstadoRadio, List<Emisora>>(
(e) => e.emisorasCustom,
);
return PluriGlassSurface(
child: Column(
+39 -18
View File
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:provider/provider.dart';
import '../estado/estado_radio.dart';
import '../estado/estado_busqueda.dart';
import '../l10n/gen/app_localizations.dart';
import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_icon.dart';
@@ -58,7 +58,7 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
void _buscar() {
final q = _controller.text.trim();
context.read<EstadoRadio>().buscar(
context.read<EstadoBusqueda>().buscar(
nombre: q.isNotEmpty ? q : null,
pais: _paisSeleccionado,
idioma: _idiomaSeleccionado,
@@ -68,7 +68,9 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
@override
Widget build(BuildContext context) {
final estado = context.watch<EstadoRadio>();
// S4-R3/S4-R5: this screen depends only on search state, so it watches
// the dedicated notifier playback events no longer rebuild it.
final estado = context.watch<EstadoBusqueda>();
final theme = Theme.of(context);
final l10n = AppLocalizations.of(context);
@@ -85,7 +87,12 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
),
),
Padding(
padding: const EdgeInsets.fromLTRB(PluriLayout.horizontal, 10, PluriLayout.horizontal, 0),
padding: const EdgeInsets.fromLTRB(
PluriLayout.horizontal,
10,
PluriLayout.horizontal,
0,
),
child: PluriGlassSurface(
padding: const EdgeInsets.all(10),
borderRadius: BorderRadius.circular(999),
@@ -132,7 +139,13 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
),
_seccionFiltroInt(
l10n.searchMinQualityFilterLabel,
const [('64 kbps', 64), ('96 kbps', 96), ('128 kbps', 128), ('192 kbps', 192), ('320 kbps', 320)],
const [
('64 kbps', 64),
('96 kbps', 96),
('128 kbps', 128),
('192 kbps', 192),
('320 kbps', 320),
],
_calidadMinima,
(v) {
setState(() => _calidadMinima = v);
@@ -144,7 +157,6 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
);
}
Widget _seccionFiltro(
String titulo,
List<(String, String)> opciones,
@@ -153,7 +165,12 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.fromLTRB(PluriLayout.horizontal, 8, PluriLayout.horizontal, 0),
padding: const EdgeInsets.fromLTRB(
PluriLayout.horizontal,
8,
PluriLayout.horizontal,
0,
),
child: PluriGlassSurface(
padding: const EdgeInsets.all(10),
child: Column(
@@ -198,7 +215,12 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.fromLTRB(PluriLayout.horizontal, 8, PluriLayout.horizontal, 0),
padding: const EdgeInsets.fromLTRB(
PluriLayout.horizontal,
8,
PluriLayout.horizontal,
0,
),
child: PluriGlassSurface(
padding: const EdgeInsets.all(10),
child: Column(
@@ -235,16 +257,16 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
);
}
Widget _resultados(EstadoRadio estado, ThemeData theme) {
Widget _resultados(EstadoBusqueda estado, ThemeData theme) {
final l10n = AppLocalizations.of(context);
if (estado.cargandoBusqueda) {
if (estado.cargando) {
return const SizedBox(
height: 220,
child: Center(child: CircularProgressIndicator()),
);
}
final resultados = estado.resultadosBusqueda;
final resultados = estado.resultados;
if (resultados.isEmpty) {
final sinFiltros =
@@ -255,8 +277,7 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
height: 260,
child: PluriEmptyState(
glyph: PluriIconGlyph.search,
title:
sinFiltros ? l10n.searchEmptyTitle : l10n.searchNoResultsTitle,
title: sinFiltros ? l10n.searchEmptyTitle : l10n.searchNoResultsTitle,
subtitle:
sinFiltros
? l10n.searchEmptySubtitle
@@ -265,7 +286,7 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
);
}
final total = resultados.length + (estado.hayMasBusqueda ? 1 : 0);
final total = resultados.length + (estado.hayMas ? 1 : 0);
return ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
@@ -274,16 +295,16 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
separatorBuilder: (_, __) => const SizedBox(height: 10),
itemBuilder: (context, i) {
if (i >= resultados.length) {
if (!estado.cargandoMasBusqueda) {
Future<void>.microtask(estado.cargarMasBusqueda);
if (!estado.cargandoMas) {
Future<void>.microtask(estado.cargarMas);
}
return const Padding(
padding: EdgeInsets.all(18),
child: Center(child: CircularProgressIndicator()),
);
}
if (i >= resultados.length - 5 && estado.hayMasBusqueda) {
Future<void>.microtask(estado.cargarMasBusqueda);
if (i >= resultados.length - 5 && estado.hayMas) {
Future<void>.microtask(estado.cargarMas);
}
return TarjetaEmisora(
emisora: resultados[i],
+51 -40
View File
@@ -19,9 +19,15 @@ class PantallaFavoritos extends StatelessWidget {
@override
Widget build(BuildContext context) {
final estado = context.watch<EstadoRadio>();
final favoritos = estado.listaFavoritos;
final grupos = estado.gruposFavoritos;
// S4-R5: no root watch select only the fields this screen reads. The
// getters are identity-memoized, so playback notifications that do not
// 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);
if (favoritos.isEmpty) {
@@ -49,16 +55,17 @@ class PantallaFavoritos extends StatelessWidget {
);
}
final gruposVisibles = grupos.isEmpty
? [
GrupoFavoritos(
id: GrupoFavoritos.sinAsignarId,
nombre: l10n.favoriteGroupsUnassigned,
orden: 0,
protegido: true,
),
]
: grupos;
final gruposVisibles =
grupos.isEmpty
? [
GrupoFavoritos(
id: GrupoFavoritos.sinAsignarId,
nombre: l10n.favoriteGroupsUnassigned,
orden: 0,
protegido: true,
),
]
: grupos;
return CustomScrollView(
slivers: [
@@ -86,9 +93,10 @@ class PantallaFavoritos extends StatelessWidget {
_GrupoFavoritosPanel(
grupo: grupo,
grupos: gruposVisibles,
emisoras: favoritos
.where((e) => e.grupoFavoritosId == grupo.id)
.toList(),
emisoras:
favoritos
.where((e) => e.grupoFavoritosId == grupo.id)
.toList(),
),
const SizedBox(height: 12),
],
@@ -125,7 +133,9 @@ class _GrupoFavoritosPanel extends StatelessWidget {
children: [
Row(
children: [
Icon(grupo.esSinAsignar ? Icons.lock_rounded : Icons.folder_rounded),
Icon(
grupo.esSinAsignar ? Icons.lock_rounded : Icons.folder_rounded,
),
const SizedBox(width: 8),
Expanded(
child: Text(
@@ -181,30 +191,31 @@ class _FavoritoItem extends StatelessWidget {
final seleccionado = await showModalBottomSheet<String>(
context: context,
showDragHandle: true,
builder: (ctx) => SafeArea(
child: ListView(
shrinkWrap: true,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 12),
child: Text(
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,
builder:
(ctx) => SafeArea(
child: ListView(
shrinkWrap: true,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 12),
child: Text(
l10n.favoriteGroupsAssign,
style: Theme.of(ctx).textTheme.titleLarge,
),
),
title: Text(_nombreVisible(l10n, grupo)),
onTap: () => Navigator.pop(ctx, grupo.id),
),
],
),
),
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)),
onTap: () => Navigator.pop(ctx, grupo.id),
),
],
),
),
);
if (seleccionado == null || !context.mounted) return;
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:shimmer/shimmer.dart' as shimmer;
import '../estado/estado_busqueda.dart';
import '../estado/estado_radio.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/emisora.dart';
import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_icon.dart';
import '../widgets/pluri_layout.dart';
@@ -40,20 +42,25 @@ class _PantallaInicioState extends State<PantallaInicio> {
@override
Widget build(BuildContext context) {
final estado = context.watch<EstadoRadio>();
// S4-R5: no root watch on EstadoRadio. Every field is consumed through
// context.select over identity-memoized getters, so audio buffer events
// (which notify EstadoRadio) no longer rebuild this screen.
final theme = Theme.of(context);
final l10n = AppLocalizations.of(context);
final error = context.select<EstadoRadio, String?>((e) => e.error);
return RefreshIndicator(
onRefresh: estado.cargarPopulares,
onRefresh: () => context.read<EstadoRadio>().cargarPopulares(),
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(child: _heroHeader(context, estado, l10n)),
SliverToBoxAdapter(child: _seccionCercanas(estado, theme, l10n)),
SliverToBoxAdapter(child: _seccionTendencias(estado, theme, l10n)),
SliverToBoxAdapter(child: _heroHeader(context, l10n)),
SliverToBoxAdapter(child: _seccionCercanas(context, theme, l10n)),
SliverToBoxAdapter(child: _seccionTendencias(context, theme, l10n)),
SliverToBoxAdapter(child: _chipGeneros(context, theme, l10n)),
if (estado.error != null)
SliverToBoxAdapter(child: _errorBanner(estado, theme, l10n)),
if (error != null)
SliverToBoxAdapter(
child: _errorBanner(context, error, theme, l10n),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(
PluriLayout.horizontal,
@@ -61,30 +68,29 @@ class _PantallaInicioState extends State<PantallaInicio> {
PluriLayout.horizontal,
PluriLayout.bottomChromeInset,
),
sliver: _gridEmisoras(estado, l10n),
sliver: _gridEmisoras(context, l10n),
),
],
),
);
}
Widget _heroHeader(
BuildContext context,
EstadoRadio estado,
AppLocalizations l10n,
) {
Widget _heroHeader(BuildContext context, AppLocalizations l10n) {
final totalEmisoras = context.select<EstadoRadio, int>(
(e) => e.emisorasInicio.length,
);
return PluriScreenHeader(
title: l10n.appTitle,
subtitle: l10n.homeScreenSubtitle,
glyph: PluriIconGlyph.home,
primaryActionLabel: l10n.exploreStations,
onPrimaryAction: estado.cargarPopulares,
onPrimaryAction: () => context.read<EstadoRadio>().cargarPopulares(),
trailing: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
PluriStatusPill(
icon: Icons.public_rounded,
label: l10n.stationsCount(estado.emisorasInicio.length),
label: l10n.stationsCount(totalEmisoras),
accent: Theme.of(context).colorScheme.secondary,
),
const SizedBox(height: 8),
@@ -95,11 +101,13 @@ class _PantallaInicioState extends State<PantallaInicio> {
}
Widget _seccionCercanas(
EstadoRadio estado,
BuildContext context,
ThemeData theme,
AppLocalizations l10n,
) {
final pais = estado.paisCercanoDetectado;
// Nearby stations live in EstadoBusqueda (S4-R3).
final busqueda = context.watch<EstadoBusqueda>();
final pais = busqueda.paisCercanoDetectado;
return Padding(
padding: const EdgeInsets.fromLTRB(
PluriLayout.horizontal,
@@ -124,11 +132,11 @@ class _PantallaInicioState extends State<PantallaInicio> {
),
TextButton.icon(
onPressed:
estado.cargandoCercanas
busqueda.cargandoCercanas
? null
: estado.cargarEmisorasCercanas,
: busqueda.cargarEmisorasCercanas,
icon:
estado.cargandoCercanas
busqueda.cargandoCercanas
? const SizedBox(
width: 16,
height: 16,
@@ -139,23 +147,23 @@ class _PantallaInicioState extends State<PantallaInicio> {
),
],
),
if (estado.errorCercanas != null)
if (busqueda.errorCercanas != null)
Text(
estado.errorCercanas!,
busqueda.errorCercanas!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
),
if (estado.emisorasCercanas.isNotEmpty) ...[
if (busqueda.cercanas.isNotEmpty) ...[
const SizedBox(height: 8),
SizedBox(
height: 76,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: estado.emisorasCercanas.length,
itemCount: busqueda.cercanas.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (context, i) {
final emisora = estado.emisorasCercanas[i];
final emisora = busqueda.cercanas[i];
return SizedBox(
width: 260,
child: TarjetaEmisora(
@@ -175,10 +183,16 @@ class _PantallaInicioState extends State<PantallaInicio> {
}
Widget _seccionTendencias(
EstadoRadio estado,
BuildContext context,
ThemeData theme,
AppLocalizations l10n,
) {
final cargando = context.select<EstadoRadio, bool>(
(e) => e.cargandoPopulares,
);
final tendencias = context.select<EstadoRadio, List<Emisora>>(
(e) => e.tendencias,
);
return Padding(
padding: const EdgeInsets.fromLTRB(
PluriLayout.horizontal,
@@ -196,7 +210,7 @@ class _PantallaInicioState extends State<PantallaInicio> {
SizedBox(
height: 56,
child:
estado.cargandoPopulares
cargando
? ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: 5,
@@ -205,10 +219,10 @@ class _PantallaInicioState extends State<PantallaInicio> {
)
: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: estado.tendencias.length,
itemCount: tendencias.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (context, i) {
final e = estado.tendencias[i];
final e = tendencias[i];
return ActionChip(
avatar: const Icon(
Icons.graphic_eq_rounded,
@@ -259,7 +273,7 @@ class _PantallaInicioState extends State<PantallaInicio> {
_generoSeleccionado = seleccionado ? null : g;
});
if (!seleccionado) {
context.read<EstadoRadio>().buscar(tag: g);
context.read<EstadoBusqueda>().buscar(tag: g);
} else {
context.read<EstadoRadio>().cargarPopulares();
}
@@ -274,7 +288,8 @@ class _PantallaInicioState extends State<PantallaInicio> {
}
Widget _errorBanner(
EstadoRadio estado,
BuildContext context,
String error,
ThemeData theme,
AppLocalizations l10n,
) {
@@ -286,9 +301,9 @@ class _PantallaInicioState extends State<PantallaInicio> {
children: [
Icon(Icons.wifi_off, color: theme.colorScheme.error),
const SizedBox(width: 8),
Expanded(child: Text(estado.error!)),
Expanded(child: Text(error)),
TextButton(
onPressed: estado.cargarPopulares,
onPressed: () => context.read<EstadoRadio>().cargarPopulares(),
child: Text(l10n.retryAction),
),
],
@@ -297,14 +312,17 @@ class _PantallaInicioState extends State<PantallaInicio> {
);
}
Widget _gridEmisoras(EstadoRadio estado, AppLocalizations l10n) {
Widget _gridEmisoras(BuildContext context, AppLocalizations l10n) {
final porGenero = _generoSeleccionado != null;
final emisoras =
_generoSeleccionado != null
? estado.resultadosBusqueda
: estado.emisorasInicio;
porGenero
? context.select<EstadoBusqueda, List<Emisora>>((b) => b.resultados)
: context.select<EstadoRadio, List<Emisora>>(
(e) => e.emisorasInicio,
);
final cargando =
estado.cargandoPopulares ||
(_generoSeleccionado != null && estado.cargandoBusqueda);
context.select<EstadoRadio, bool>((e) => e.cargandoPopulares) ||
(porGenero && context.select<EstadoBusqueda, bool>((b) => b.cargando));
if (cargando) {
return SliverGrid(
+17 -11
View File
@@ -5,6 +5,7 @@ import 'package:provider/provider.dart';
import 'package:shimmer/shimmer.dart';
import '../estado/estado_ecualizador.dart';
import '../estado/estado_grabacion.dart';
import '../estado/estado_radio.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/emisora.dart';
@@ -177,7 +178,7 @@ class _PantallaReproductorState extends State<PantallaReproductor>
emisora: emisoraActiva,
).animate().fadeIn(delay: 300.ms).slideY(begin: 0.3),
const SizedBox(height: 14),
_GrabacionWidget(estado: estado).animate().fadeIn(delay: 360.ms),
const _GrabacionWidget().animate().fadeIn(delay: 360.ms),
const SizedBox(height: 14),
_TimerWidget(estado: estado).animate().fadeIn(delay: 400.ms),
const SizedBox(height: 16),
@@ -358,16 +359,18 @@ class _InfoChips extends StatelessWidget {
}
class _GrabacionWidget extends StatelessWidget {
final EstadoRadio estado;
const _GrabacionWidget({required this.estado});
// Recording state lives in EstadoGrabacion (S4-R2); EstadoRadio no longer
// notifies on recording progress, so this widget watches the new notifier.
const _GrabacionWidget();
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.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 hayUltimaGrabacion = estado.ultimaGrabacion != null;
final hayUltimaGrabacion = estado.ultimoArchivo != null;
return PluriGlassSurface(
borderRadius: BorderRadius.circular(24),
@@ -416,7 +419,7 @@ class _GrabacionWidget extends StatelessWidget {
label: Text(activa ? l10n.stopAction : l10n.recordAction),
onPressed:
activa
? estado.detenerGrabacion
? estado.detener
: () => _mostrarDialogoGrabacion(context),
),
if (!activa)
@@ -440,7 +443,8 @@ class _GrabacionWidget extends StatelessWidget {
Future<void> _abrirUltimaGrabacion(BuildContext context) async {
final messenger = ScaffoldMessenger.of(context);
final abierto = await estado.abrirUltimaGrabacion();
final abierto =
await context.read<EstadoGrabacion>().abrirUltimaGrabacion();
if (!context.mounted) return;
if (!abierto) {
messenger.showSnackBar(
@@ -453,7 +457,7 @@ class _GrabacionWidget extends StatelessWidget {
Future<void> _abrirCarpetaGrabaciones(BuildContext context) async {
final messenger = ScaffoldMessenger.of(context);
final abierto = await estado.abrirDirectorioGrabacion();
final abierto = await context.read<EstadoGrabacion>().abrirDirectorio();
if (!context.mounted) return;
if (!abierto) {
messenger.showSnackBar(
@@ -467,6 +471,7 @@ class _GrabacionWidget extends StatelessWidget {
}
void _mostrarDialogoGrabacion(BuildContext context) {
final grabacion = context.read<EstadoGrabacion>();
showModalBottomSheet(
context: context,
builder:
@@ -495,7 +500,7 @@ class _GrabacionWidget extends StatelessWidget {
),
label: Text(AppLocalizations.of(ctx).indefiniteOption),
onPressed: () {
estado.iniciarGrabacion();
grabacion.iniciar();
Navigator.pop(ctx);
},
),
@@ -511,7 +516,7 @@ class _GrabacionWidget extends StatelessWidget {
),
),
onPressed: () {
estado.iniciarGrabacion(duracion: opcion.duracion);
grabacion.iniciar(duracion: opcion.duracion);
Navigator.pop(ctx);
},
),
@@ -533,6 +538,7 @@ class _GrabacionWidget extends StatelessWidget {
}
Future<void> _mostrarDuracionPersonalizada(BuildContext context) async {
final grabacion = context.read<EstadoGrabacion>();
final minutosCtrl = TextEditingController();
final segundosCtrl = TextEditingController(text: '0');
final formKey = GlobalKey<FormState>();
@@ -585,7 +591,7 @@ class _GrabacionWidget extends StatelessWidget {
seconds: segundos,
);
if (duracion <= Duration.zero) return;
estado.iniciarGrabacion(duracion: duracion);
grabacion.iniciar(duracion: duracion);
Navigator.pop(ctx);
},
child: Text(AppLocalizations.of(ctx).recordAction),
@@ -3,7 +3,7 @@
**Mode**: Strict TDD (test runner: `flutter test`)
**Artifact store**: openspec (Engram unavailable this session)
**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
@@ -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 |
| 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 |
| 6 | S4b — EstadoGrabacion + EstadoBusqueda + scoped rebuilds + compat-getter removal | COMPLETE (Dart-only batch) | 2026-06-11 |
## Task status (cumulative)
@@ -146,9 +147,27 @@
| 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 |
### 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)
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)
@@ -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.
### 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)
| 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.
## 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)
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).
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
- Mode: auto-chain local slices (no PRs)
- Current work units: S1, S2a, S2b, S3a, S3b, S7 (committed, latest 0380bbb), S4a (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).
- 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).
- Current work units: S1, S2a, S2b, S3a, S3b, S7, S4a (committed, latest 0416b30), S4b (complete, in working tree)
- 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: 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
- [ ] **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.**
- [ ] **T-S4b-02** [RED] Create `test/estado/estado_busqueda_test.dart`: search query update notifies `EstadoBusqueda` listeners. (S4-R3) **~15 lines.**
- [ ] **T-S4b-03** [RED] Add widget test: changing EQ preset does NOT rebuild `PantallaInicio` (S4-R5-A). **~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.**
- [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).**
- [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
- [ ] **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.**
- [ ] **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.**
- [ ] **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.**
- [ ] **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.**
- [ ] **T-S4b-08** [GREEN] Edit `lib/pantallas/pantalla_favoritos.dart`: scope the `EstadoRadio` watch. **Reqs:** S4-R5. **~15 lines.**
- [ ] **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-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.**
- [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.**
- [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.**
- [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.**
- [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).**
- [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
- [ ] **T-S4b-10** Run `flutter test test/estado/estado_grabacion_test.dart test/estado/estado_busqueda_test.dart` plus the rebuild scope test.
- [ ] **T-S4b-11** Run `flutter test` (full suite) — no regressions.
- [ ] **T-S4b-12** Run `flutter analyze`zero errors.
- [ ] **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-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).
- [x] **T-S4b-11** Run `flutter test` (full suite) — 110/110 passing (103 baseline 1 moved pagination test + 8 new), no regressions.
- [x] **T-S4b-12** Run `flutter analyze``No issues found!`.
- [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
- `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.reproducir(emisora);
expect(estado.presetEcualizador, principal);
expect(estado.ecualizador.presetActual, principal);
expect(audio.presetsAplicados.first, principal);
expect(audio.presetsAplicados.last, principal);
},
@@ -85,11 +85,11 @@ void main() {
await estado.inicializar();
expect(estado.ecualizadorDisponible, isFalse);
expect(estado.presetEcualizador, principal);
expect(estado.presetPrincipalEcualizador, principal);
expect(estado.ecualizador.disponible, isFalse);
expect(estado.ecualizador.presetActual, principal);
expect(estado.ecualizador.presetPrincipal, principal);
expect(
estado.presetEcualizadorPorEmisora('fav-1'),
estado.ecualizador.presetPorEmisora('fav-1'),
PresetEcualizador.rock,
);
},
@@ -151,15 +151,15 @@ void main() {
await estado.inicializar();
await estado.cargarFavoritos();
await estado.guardarPresetEcualizadorPorEmisora(emisora.uuid, propio);
await estado.ecualizador.guardarPresetPorEmisora(emisora.uuid, propio);
await estado.reproducir(emisora);
expect(estado.presetEcualizador, propio);
expect(estado.ecualizador.presetActual, propio);
expect(audio.presetsAplicados.last, propio);
await estado.deshabilitarPresetEcualizadorPorEmisora(emisora.uuid);
await estado.ecualizador.deshabilitarPresetPorEmisora(emisora.uuid);
await estado.reproducir(emisora);
expect(estado.presetEcualizador, principal);
expect(estado.ecualizador.presetActual, principal);
expect(audio.presetsAplicados.last, principal);
},
);
@@ -186,8 +186,8 @@ void main() {
await estado.cargarFavoritos();
await estado.reproducir(emisora);
expect(estado.tienePresetEcualizadorPorEmisora(emisora.uuid), isFalse);
expect(estado.presetEcualizador, principal);
expect(estado.ecualizador.tienePresetPorEmisora(emisora.uuid), isFalse);
expect(estado.ecualizador.presetActual, principal);
expect(audio.presetsAplicados.last, principal);
},
);
@@ -207,15 +207,15 @@ void main() {
);
await estado.inicializar();
expect(estado.ecualizadorActivo, isTrue);
expect(estado.ecualizador.activo, isTrue);
await estado.cambiarEcualizadorActivo(false);
expect(estado.ecualizadorActivo, isFalse);
await estado.ecualizador.cambiarActivo(false);
expect(estado.ecualizador.activo, isFalse);
expect(servicioEcualizador.config.activo, isFalse);
expect(audio.cambiosEcualizadorActivo.last, isFalse);
await estado.cambiarEcualizadorActivo(true);
expect(estado.ecualizadorActivo, isTrue);
await estado.ecualizador.cambiarActivo(true);
expect(estado.ecualizador.activo, isTrue);
expect(servicioEcualizador.config.activo, isTrue);
expect(audio.cambiosEcualizadorActivo.last, isTrue);
},
@@ -285,11 +285,11 @@ void main() {
);
await estado.inicializar();
await estado.guardarPresetEcualizadorPorEmisora(
await estado.ecualizador.guardarPresetPorEmisora(
primera.uuid,
PresetEcualizador.rock,
);
await estado.guardarPresetEcualizadorPorEmisora(
await estado.ecualizador.guardarPresetPorEmisora(
segunda.uuid,
PresetEcualizador.jazz,
);
@@ -301,7 +301,7 @@ void main() {
audio.completar(primera.uuid);
await primeraFuture;
expect(estado.presetEcualizador, PresetEcualizador.jazz);
expect(estado.ecualizador.presetActual, PresetEcualizador.jazz);
expect(radio.ultimoUuidClick, segunda.uuid);
},
);
@@ -319,32 +319,8 @@ void main() {
expect(lista.map((e) => e.orden).toList(), equals([0, 1, 2]));
});
test('cargarMasBusqueda pagina resultados y acota memoria', () async {
final emisoras = List.generate(
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);
});
// The search pagination test moved to test/estado/estado_busqueda_test.dart
// (S4-R3: search state extracted to EstadoBusqueda).
test('toggleFavorito refresca lista global y evita estado stale', () async {
final favoritos = FakeServicioFavoritos();
@@ -388,16 +364,10 @@ void main() {
final grupo = estado.gruposFavoritos.last;
await estado.asignarGrupoFavorito(emisora.uuid, grupo.id);
expect(
estado.listaFavoritos.first.grupoFavoritosId,
grupo.id,
);
expect(estado.listaFavoritos.first.grupoFavoritosId, grupo.id);
await estado.eliminarGrupoFavoritos(grupo.id);
expect(
estado.listaFavoritos.first.grupoFavoritosId,
'sin_asignar',
);
expect(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_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/pantallas/pantalla_favoritos.dart';
@@ -41,10 +44,7 @@ void main() {
await tester.runAsync(estado.inicializar);
await tester.pumpWidget(
ChangeNotifierProvider<EstadoRadio>.value(
value: estado,
child: _testApp(const PantallaInicio()),
),
_conProviders(estado, _testApp(const PantallaInicio())),
);
await _pumpStableFrame(tester);
@@ -109,10 +109,7 @@ void main() {
await tester.runAsync(estado.inicializar);
await tester.pumpWidget(
ChangeNotifierProvider<EstadoRadio>.value(
value: estado,
child: _testApp(const PantallaInicio()),
),
_conProviders(estado, _testApp(const PantallaInicio())),
);
await _pumpStableFrame(tester);
@@ -153,10 +150,7 @@ void main() {
await tester.runAsync(estado.inicializar);
await tester.pumpWidget(
ChangeNotifierProvider<EstadoRadio>.value(
value: estado,
child: _testApp(const PantallaInicio()),
),
_conProviders(estado, _testApp(const PantallaInicio())),
);
await _pumpStableFrame(tester);
@@ -176,10 +170,7 @@ void main() {
await _pumpStableFrame(tester);
await tester.pumpWidget(
ChangeNotifierProvider<EstadoRadio>.value(
value: estado,
child: _testApp(const PantallaFavoritos()),
),
_conProviders(estado, _testApp(const PantallaFavoritos())),
);
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) {
return MaterialApp(
locale: const Locale('es'),