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