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