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:
+12
-3
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
+90
-387
@@ -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,18 +193,9 @@ 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 {
|
||||
List<Emisora> get emisorasInicio =>
|
||||
_memoInicio.obtener([_emisorasCustom, _populares], () {
|
||||
final mapa = <String, Emisora>{};
|
||||
for (final emisora in _emisorasCustom) {
|
||||
mapa[emisora.uuid] = emisora;
|
||||
@@ -210,9 +204,18 @@ class EstadoRadio extends ChangeNotifier {
|
||||
mapa.putIfAbsent(emisora.uuid, () => emisora);
|
||||
}
|
||||
return mapa.values.toList();
|
||||
}
|
||||
});
|
||||
|
||||
List<Emisora> get emisorasDisponiblesPreferencia {
|
||||
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;
|
||||
@@ -226,14 +229,15 @@ class EstadoRadio extends ChangeNotifier {
|
||||
for (final emisora in _tendencias) {
|
||||
mapa.putIfAbsent(emisora.uuid, () => emisora);
|
||||
}
|
||||
for (final emisora in _resultadosBusqueda) {
|
||||
for (final emisora in busqueda.resultados) {
|
||||
mapa.putIfAbsent(emisora.uuid, () => emisora);
|
||||
}
|
||||
for (final emisora in _emisorasCercanas) {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,7 +55,8 @@ class PantallaFavoritos extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
final gruposVisibles = grupos.isEmpty
|
||||
final gruposVisibles =
|
||||
grupos.isEmpty
|
||||
? [
|
||||
GrupoFavoritos(
|
||||
id: GrupoFavoritos.sinAsignarId,
|
||||
@@ -86,7 +93,8 @@ class PantallaFavoritos extends StatelessWidget {
|
||||
_GrupoFavoritosPanel(
|
||||
grupo: grupo,
|
||||
grupos: gruposVisibles,
|
||||
emisoras: favoritos
|
||||
emisoras:
|
||||
favoritos
|
||||
.where((e) => e.grupoFavoritosId == grupo.id)
|
||||
.toList(),
|
||||
),
|
||||
@@ -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,7 +191,8 @@ class _FavoritoItem extends StatelessWidget {
|
||||
final seleccionado = await showModalBottomSheet<String>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (ctx) => SafeArea(
|
||||
builder:
|
||||
(ctx) => SafeArea(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 D1–D5 / S1–S5)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user