From 7fcd0f544e199583c72eced999251d119394871f Mon Sep 17 00:00:00 2001 From: freetlab Date: Wed, 20 May 2026 23:22:15 +0200 Subject: [PATCH] feat(radio): add nearby discovery and paged search --- android/app/src/main/AndroidManifest.xml | 2 + ios/Runner/Info.plist | 2 + lib/estado/estado_radio.dart | 126 ++++++++++++++++-- lib/pantallas/pantalla_buscar.dart | 99 +++++++++++++- lib/pantallas/pantalla_favoritos.dart | 4 +- lib/pantallas/pantalla_inicio.dart | 76 ++++++++++- ..._abrir.dart => reproducir_minimizado.dart} | 5 +- lib/servicios/servicio_audio.dart | 11 +- lib/servicios/servicio_radio.dart | 23 +++- pubspec.lock | 80 +++++++++++ pubspec.yaml | 2 + test/estado/estado_radio_test.dart | 33 ++++- test/helpers/fakes.dart | 5 +- 13 files changed, 428 insertions(+), 40 deletions(-) rename lib/pantallas/{reproducir_y_abrir.dart => reproducir_minimizado.dart} (57%) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 91532d7..7e6408f 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -4,6 +4,8 @@ + + UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + NSLocationWhenInUseUsageDescription + PluriWave usa tu ubicacion aproximada para sugerirte emisoras cercanas. diff --git a/lib/estado/estado_radio.dart b/lib/estado/estado_radio.dart index 2de230b..85d3443 100644 --- a/lib/estado/estado_radio.dart +++ b/lib/estado/estado_radio.dart @@ -3,6 +3,8 @@ import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; +import 'package:geocoding/geocoding.dart'; +import 'package:geolocator/geolocator.dart'; import 'package:path_provider/path_provider.dart'; import '../modelos/emisora.dart'; @@ -51,6 +53,7 @@ class EstadoRadio extends ChangeNotifier { List _populares = []; List _tendencias = []; List _resultadosBusqueda = []; + List _emisorasCercanas = []; List _listaFavoritos = []; List _emisorasCustom = []; @@ -61,16 +64,31 @@ class EstadoRadio extends ChangeNotifier { bool _cargandoPopulares = false; bool _cargandoBusqueda = false; - EstadoReproduccion _estadoReproduccion = EstadoReproduccion.detenido; + bool _cargandoMasBusqueda = false; + bool _hayMasBusqueda = true; + bool _cargandoCercanas = false; + String? _paisCercanoDetectado; + String? _errorCercanas; + int _offsetBusqueda = 0; + String? _ultimoNombreBusqueda; + String? _ultimoPaisBusqueda; + String? _ultimoIdiomaBusqueda; + String? _ultimoTagBusqueda; String? _errorCarga; List get populares => _populares; List get tendencias => _tendencias; List get resultadosBusqueda => _resultadosBusqueda; + List get emisorasCercanas => _emisorasCercanas; List get listaFavoritos => _listaFavoritos; List get emisorasCustom => _emisorasCustom; 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 => audio.emisoraActual; Stream get estadoStream => audio.estadoStream; @@ -119,7 +137,6 @@ class EstadoRadio extends ChangeNotifier { /// Escucha el stream de estado del audio y gestiona errores de reproducción. void _escucharErroresReproduccion() { _suscripcionEstadoAudio = audio.estadoStream.listen((estado) { - _estadoReproduccion = estado; if (estado == EstadoReproduccion.error && timer.activo) { unawaited(timer.cancelar()); } @@ -167,42 +184,125 @@ class EstadoRadio extends ChangeNotifier { notifyListeners(); } + static const int _tamanoPaginaBusqueda = 30; + static const int _maxResultadosBusquedaEnMemoria = 180; + Future buscar({ String? nombre, String? pais, String? idioma, String? tag, }) async { + _ultimoNombreBusqueda = nombre; + _ultimoPaisBusqueda = pais; + _ultimoIdiomaBusqueda = idioma; + _ultimoTagBusqueda = tag; + _offsetBusqueda = 0; + _hayMasBusqueda = true; _cargandoBusqueda = true; _resultadosBusqueda = []; notifyListeners(); try { - _resultadosBusqueda = await radio.buscar( + final pagina = await radio.buscar( nombre: nombre, pais: pais, idioma: idioma, tag: tag, + limit: _tamanoPaginaBusqueda, + offset: _offsetBusqueda, ); + _resultadosBusqueda = pagina; + _offsetBusqueda = pagina.length; + _hayMasBusqueda = pagina.length == _tamanoPaginaBusqueda; } catch (_) { - _errorController.add('Error en la búsqueda. Comprueba tu conexión.'); + _errorController.add('Error en la busqueda. Comprueba tu conexion.'); } finally { _cargandoBusqueda = false; notifyListeners(); } } - Future reproducir(Emisora emisora) async { - final actual = audio.emisoraActual; - final mismaEmisoraActiva = actual?.uuid == emisora.uuid; - final yaEstaConectandoOSonando = - _estadoReproduccion == EstadoReproduccion.cargando || - _estadoReproduccion == EstadoReproduccion.reproduciendo || - audio.estaSonando; - if (mismaEmisoraActiva && yaEstaConectandoOSonando) { + Future cargarMasBusqueda() async { + if (_cargandoBusqueda || _cargandoMasBusqueda || !_hayMasBusqueda) return; + _cargandoMasBusqueda = true; + notifyListeners(); + try { + final pagina = await radio.buscar( + nombre: _ultimoNombreBusqueda, + pais: _ultimoPaisBusqueda, + idioma: _ultimoIdiomaBusqueda, + tag: _ultimoTagBusqueda, + limit: _tamanoPaginaBusqueda, + offset: _offsetBusqueda, + ); + 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; + _offsetBusqueda += pagina.length; + _hayMasBusqueda = pagina.length == _tamanoPaginaBusqueda; + } catch (_) { + _errorController.add('No se pudieron cargar mas emisoras.'); + } finally { + _cargandoMasBusqueda = false; notifyListeners(); - return; } + } + 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 Exception('No se pudo detectar tu region'); + } + _paisCercanoDetectado = pais; + _emisorasCercanas = await radio.buscar(pais: pais, limit: 30); + } catch (_) { + _errorCercanas = 'No pudimos detectar emisoras cercanas. Usa filtros por pais.'; + _emisorasCercanas = []; + } finally { + _cargandoCercanas = false; + notifyListeners(); + } + } + + Future reproducir(Emisora emisora) async { try { notifyListeners(); await audio.reproducir(emisora); diff --git a/lib/pantallas/pantalla_buscar.dart b/lib/pantallas/pantalla_buscar.dart index 124b011..7a8220a 100644 --- a/lib/pantallas/pantalla_buscar.dart +++ b/lib/pantallas/pantalla_buscar.dart @@ -8,7 +8,7 @@ import '../widgets/pluri_icon.dart'; import '../widgets/pluri_premium_widgets.dart'; import 'package:pluriwave/widgets/tarjeta_emisora.dart'; -import 'reproducir_y_abrir.dart'; +import 'reproducir_minimizado.dart'; const _paises = [ ('Espana', 'ES'), @@ -106,6 +106,7 @@ class _PantallaBuscarState extends State { ), ), ), + _seccionCercanas(estado), _seccionFiltro( 'Pais', _paises.map((p) => (p.$1, p.$2)).toList(), @@ -129,6 +130,76 @@ class _PantallaBuscarState extends State { ); } + Widget _seccionCercanas(EstadoRadio estado) { + final theme = Theme.of(context); + final pais = estado.paisCercanoDetectado; + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: PluriGlassSurface( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + pais == null ? 'Emisoras cercanas' : 'Emisoras cercanas ? $pais', + style: theme.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w900, + ), + ), + ), + TextButton.icon( + onPressed: estado.cargandoCercanas + ? null + : estado.cargarEmisorasCercanas, + icon: estado.cargandoCercanas + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.my_location_rounded, size: 18), + label: const Text('Buscar cerca'), + ), + ], + ), + if (estado.errorCercanas != null) + Text( + estado.errorCercanas!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + ), + ), + if (estado.emisorasCercanas.isNotEmpty) ...[ + const SizedBox(height: 8), + SizedBox( + height: 76, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: estado.emisorasCercanas.length, + separatorBuilder: (_, __) => const SizedBox(width: 8), + itemBuilder: (context, i) { + final emisora = estado.emisorasCercanas[i]; + return SizedBox( + width: 260, + child: TarjetaEmisora( + emisora: emisora, + esCompacta: true, + onTap: () => reproducirMinimizado(context, emisora), + ), + ); + }, + ), + ), + ], + ], + ), + ), + ); + } + Widget _seccionFiltro( String titulo, List<(String, String)> opciones, @@ -197,13 +268,27 @@ class _PantallaBuscarState extends State { return ListView.separated( padding: const EdgeInsets.fromLTRB(16, 16, 16, 120), - itemCount: resultados.length, + itemCount: resultados.length + (estado.hayMasBusqueda ? 1 : 0), separatorBuilder: (_, __) => const SizedBox(height: 10), - itemBuilder: (context, i) => TarjetaEmisora( - emisora: resultados[i], - esCompacta: true, - onTap: () => reproducirYAbrir(context, resultados[i]), - ).animate().fadeIn(delay: (i * 20).ms).slideY(begin: 0.08), + itemBuilder: (context, i) { + if (i >= resultados.length) { + if (!estado.cargandoMasBusqueda) { + Future.microtask(estado.cargarMasBusqueda); + } + return const Padding( + padding: EdgeInsets.all(18), + child: Center(child: CircularProgressIndicator()), + ); + } + if (i >= resultados.length - 5 && estado.hayMasBusqueda) { + Future.microtask(estado.cargarMasBusqueda); + } + return TarjetaEmisora( + emisora: resultados[i], + esCompacta: true, + onTap: () => reproducirMinimizado(context, resultados[i]), + ).animate().fadeIn(delay: (i.clamp(0, 12) * 20).ms).slideY(begin: 0.08); + }, ); } } diff --git a/lib/pantallas/pantalla_favoritos.dart b/lib/pantallas/pantalla_favoritos.dart index 9dd52a3..ef78b43 100644 --- a/lib/pantallas/pantalla_favoritos.dart +++ b/lib/pantallas/pantalla_favoritos.dart @@ -7,7 +7,7 @@ import '../widgets/pluri_icon.dart'; import '../widgets/pluri_premium_widgets.dart'; import 'package:pluriwave/widgets/tarjeta_emisora.dart'; -import 'reproducir_y_abrir.dart'; +import 'reproducir_minimizado.dart'; class PantallaFavoritos extends StatelessWidget { const PantallaFavoritos({super.key}); @@ -87,7 +87,7 @@ class PantallaFavoritos extends StatelessWidget { key: Key(emisora.uuid), emisora: emisora, esCompacta: true, - onTap: () => reproducirYAbrir(context, emisora), + onTap: () => reproducirMinimizado(context, emisora), ), ), IconButton.filledTonal( diff --git a/lib/pantallas/pantalla_inicio.dart b/lib/pantallas/pantalla_inicio.dart index ce6a1f7..f04fe48 100644 --- a/lib/pantallas/pantalla_inicio.dart +++ b/lib/pantallas/pantalla_inicio.dart @@ -10,7 +10,7 @@ import '../widgets/pluri_icon.dart'; import '../widgets/pluri_premium_widgets.dart'; import 'package:pluriwave/widgets/tarjeta_emisora.dart'; -import 'reproducir_y_abrir.dart'; +import 'reproducir_minimizado.dart'; /// Pantalla principal: emisoras populares y por género. class PantallaInicio extends StatefulWidget { @@ -49,6 +49,7 @@ class _PantallaInicioState extends State { slivers: [ SliverToBoxAdapter(child: _heroHeader(context, estado)), const SliverToBoxAdapter(child: _AuroraWaveBanner()), + SliverToBoxAdapter(child: _seccionCercanas(estado, theme)), SliverToBoxAdapter(child: _seccionTendencias(estado, theme)), SliverToBoxAdapter(child: _chipGeneros(context, theme)), if (estado.error != null) @@ -87,6 +88,75 @@ class _PantallaInicioState extends State { ); } + Widget _seccionCercanas(EstadoRadio estado, ThemeData theme) { + final pais = estado.paisCercanoDetectado; + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: PluriGlassSurface( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + pais == null ? 'Cerca de vos' : 'Cerca de vos ? $pais', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w900, + ), + ), + ), + TextButton.icon( + onPressed: estado.cargandoCercanas + ? null + : estado.cargarEmisorasCercanas, + icon: estado.cargandoCercanas + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.my_location_rounded, size: 18), + label: const Text('Detectar'), + ), + ], + ), + if (estado.errorCercanas != null) + Text( + estado.errorCercanas!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + ), + ), + if (estado.emisorasCercanas.isNotEmpty) ...[ + const SizedBox(height: 8), + SizedBox( + height: 76, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: estado.emisorasCercanas.length, + separatorBuilder: (_, __) => const SizedBox(width: 8), + itemBuilder: (context, i) { + final emisora = estado.emisorasCercanas[i]; + return SizedBox( + width: 260, + child: TarjetaEmisora( + emisora: emisora, + esCompacta: true, + onTap: () => reproducirMinimizado(context, emisora), + ), + ); + }, + ), + ), + ], + ], + ), + ), + ); + } + Widget _seccionTendencias(EstadoRadio estado, ThemeData theme) { return Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), @@ -120,7 +190,7 @@ class _PantallaInicioState extends State { ), label: Text(e.nombre, maxLines: 1), onPressed: - () => reproducirYAbrir(context, e), + () => reproducirMinimizado(context, e), ).animate().fadeIn(delay: (i * 50).ms); }, ), @@ -227,7 +297,7 @@ class _PantallaInicioState extends State { delegate: SliverChildBuilderDelegate( (context, i) => TarjetaEmisora( emisora: emisoras[i], - onTap: () => reproducirYAbrir(context, emisoras[i]), + onTap: () => reproducirMinimizado(context, emisoras[i]), ).animate().fadeIn(delay: (i * 30).ms).slideY(begin: 0.1), childCount: emisoras.length, ), diff --git a/lib/pantallas/reproducir_y_abrir.dart b/lib/pantallas/reproducir_minimizado.dart similarity index 57% rename from lib/pantallas/reproducir_y_abrir.dart rename to lib/pantallas/reproducir_minimizado.dart index 1935544..579f6cd 100644 --- a/lib/pantallas/reproducir_y_abrir.dart +++ b/lib/pantallas/reproducir_minimizado.dart @@ -5,11 +5,8 @@ import 'package:provider/provider.dart'; import '../estado/estado_radio.dart'; import '../modelos/emisora.dart'; -import 'pantalla_reproductor.dart'; -Future reproducirYAbrir(BuildContext context, Emisora emisora) async { +void reproducirMinimizado(BuildContext context, Emisora emisora) { final estado = context.read(); unawaited(estado.reproducir(emisora)); - if (!context.mounted) return; - await PantallaReproductor.abrir(context, emisora); } diff --git a/lib/servicios/servicio_audio.dart b/lib/servicios/servicio_audio.dart index 961a528..56a80b0 100644 --- a/lib/servicios/servicio_audio.dart +++ b/lib/servicios/servicio_audio.dart @@ -106,6 +106,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler { PresetEcualizador _presetActual = PresetEcualizador.flat; PresetEcualizador get presetActual => _presetActual; + Future _colaReproduccion = Future.value(); PluriWaveAudioHandler() { _setupStreams(); @@ -213,12 +214,20 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler { } @override - Future playMediaItem(MediaItem mediaItem) async { + Future playMediaItem(MediaItem mediaItem) { + _colaReproduccion = _colaReproduccion + .catchError((_) {}) + .then((_) => _playMediaItemSerializado(mediaItem)); + return _colaReproduccion; + } + + Future _playMediaItemSerializado(MediaItem mediaItem) async { this.mediaItem.add(mediaItem); emisoraActual = _emisoraDesdeMediaItem(mediaItem); playbackState.add(playbackState.value.copyWith( processingState: AudioProcessingState.loading, playing: false, + errorMessage: null, )); try { await _player.stop(); diff --git a/lib/servicios/servicio_radio.dart b/lib/servicios/servicio_radio.dart index 425871e..1445f7e 100644 --- a/lib/servicios/servicio_radio.dart +++ b/lib/servicios/servicio_radio.dart @@ -105,8 +105,13 @@ class ServicioRadio { } /// Emisoras más votadas globalmente. - Future> obtenerPopulares({int limit = 30}) async { - return _get('/json/stations/topvote/$limit', {}); + Future> obtenerPopulares({int limit = 30, int offset = 0}) async { + return _get('/json/stations/search', { + 'limit': limit.toString(), + 'offset': offset.toString(), + 'order': 'votes', + 'reverse': 'true', + }); } /// Emisoras más escuchadas (por clicks) globalmente. @@ -115,37 +120,41 @@ class ServicioRadio { } /// Buscar por nombre de emisora. - Future> buscarPorNombre(String query, {int limit = 30}) async { + Future> buscarPorNombre(String query, {int limit = 30, int offset = 0}) async { return _get('/json/stations/search', { 'name': query, 'limit': limit.toString(), + 'offset': offset.toString(), 'order': 'votes', 'reverse': 'true', }); } /// Buscar por código de país (ISO 3166-1 alpha-2, e.g. 'ES', 'US'). - Future> buscarPorPais(String codigoPais, {int limit = 50}) async { + Future> buscarPorPais(String codigoPais, {int limit = 50, int offset = 0}) async { return _get('/json/stations/bycountrycodeexact/$codigoPais', { 'limit': limit.toString(), + 'offset': offset.toString(), 'order': 'votes', 'reverse': 'true', }); } /// Buscar por idioma (e.g. 'spanish', 'english'). - Future> buscarPorIdioma(String idioma, {int limit = 30}) async { + Future> buscarPorIdioma(String idioma, {int limit = 30, int offset = 0}) async { return _get('/json/stations/bylanguageexact/$idioma', { 'limit': limit.toString(), + 'offset': offset.toString(), 'order': 'votes', 'reverse': 'true', }); } /// Buscar por tag/género (e.g. 'rock', 'jazz', 'pop'). - Future> buscarPorTag(String tag, {int limit = 30}) async { + Future> buscarPorTag(String tag, {int limit = 30, int offset = 0}) async { return _get('/json/stations/bytagexact/$tag', { 'limit': limit.toString(), + 'offset': offset.toString(), 'order': 'votes', 'reverse': 'true', }); @@ -158,6 +167,7 @@ class ServicioRadio { String? idioma, String? tag, int limit = 30, + int offset = 0, }) async { return _get('/json/stations/search', { if (nombre != null && nombre.isNotEmpty) 'name': nombre, @@ -165,6 +175,7 @@ class ServicioRadio { if (idioma != null && idioma.isNotEmpty) 'language': idioma, if (tag != null && tag.isNotEmpty) 'tag': tag, 'limit': limit.toString(), + 'offset': offset.toString(), 'order': 'votes', 'reverse': 'true', }); diff --git a/pubspec.lock b/pubspec.lock index c2466b8..3c3b953 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -229,6 +229,86 @@ packages: description: flutter source: sdk version: "0.0.0" + geocoding: + dependency: "direct main" + description: + name: geocoding + sha256: d580c801cba9386b4fac5047c4c785a4e19554f46be42f4f5e5b7deacd088a66 + url: "https://pub.dev" + source: hosted + version: "3.0.0" + geocoding_android: + dependency: transitive + description: + name: geocoding_android + sha256: "1b13eca79b11c497c434678fed109c2be020b158cec7512c848c102bc7232603" + url: "https://pub.dev" + source: hosted + version: "3.3.1" + geocoding_ios: + dependency: transitive + description: + name: geocoding_ios + sha256: "18ab1c8369e2b0dcb3a8ccc907319334f35ee8cf4cfef4d9c8e23b13c65cb825" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + geocoding_platform_interface: + dependency: transitive + description: + name: geocoding_platform_interface + sha256: "8c2c8226e5c276594c2e18bfe88b19110ed770aeb7c1ab50ede570be8b92229b" + url: "https://pub.dev" + source: hosted + version: "3.2.0" + geolocator: + dependency: "direct main" + description: + name: geolocator + sha256: f62bcd90459e63210bbf9c35deb6a51c521f992a78de19a1fe5c11704f9530e2 + url: "https://pub.dev" + source: hosted + version: "13.0.4" + geolocator_android: + dependency: transitive + description: + name: geolocator_android + sha256: fcb1760a50d7500deca37c9a666785c047139b5f9ee15aa5469fae7dbbe3170d + url: "https://pub.dev" + source: hosted + version: "4.6.2" + geolocator_apple: + dependency: transitive + description: + name: geolocator_apple + sha256: dbdd8789d5aaf14cf69f74d4925ad1336b4433a6efdf2fce91e8955dc921bf22 + url: "https://pub.dev" + source: hosted + version: "2.3.13" + geolocator_platform_interface: + dependency: transitive + description: + name: geolocator_platform_interface + sha256: "30cb64f0b9adcc0fb36f628b4ebf4f731a2961a0ebd849f4b56200205056fe67" + url: "https://pub.dev" + source: hosted + version: "4.2.6" + geolocator_web: + dependency: transitive + description: + name: geolocator_web + sha256: b1ae9bdfd90f861fde8fd4f209c37b953d65e92823cb73c7dee1fa021b06f172 + url: "https://pub.dev" + source: hosted + version: "4.1.3" + geolocator_windows: + dependency: transitive + description: + name: geolocator_windows + sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6" + url: "https://pub.dev" + source: hosted + version: "0.2.5" glob: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index ba5810d..ee1957e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -41,6 +41,8 @@ dependencies: file_picker: ^8.1.7 uuid: ^4.5.1 url_launcher: ^6.3.1 + geolocator: ^13.0.4 + geocoding: ^3.0.0 # Ads (activar cuando tengamos Ad Unit IDs) # google_mobile_ads: ^5.3.0 diff --git a/test/estado/estado_radio_test.dart b/test/estado/estado_radio_test.dart index 31ffd41..4637012 100644 --- a/test/estado/estado_radio_test.dart +++ b/test/estado/estado_radio_test.dart @@ -197,7 +197,7 @@ void main() { expect(notificaciones, greaterThan(antes)); }); - test('reproducir la misma emisora mientras suena no reinicia el stream', () async { + test('reproducir la misma emisora mientras suena fuerza recarga del stream', () async { final audio = FakeServicioAudio(); final emisora = emisoraDemo(uuid: 'same-1', nombre: 'Misma'); final estado = EstadoRadio( @@ -213,7 +213,7 @@ void main() { await estado.reproducir(emisora); await estado.reproducir(emisora); - expect(audio.emisorasReproducidas, hasLength(1)); + expect(audio.emisorasReproducidas, hasLength(2)); }); test('reordenar favoritos reindexa de forma determinística', () async { @@ -229,6 +229,35 @@ 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); + }); + test('toggleFavorito refresca lista global y evita estado stale', () async { final favoritos = FakeServicioFavoritos(); final emisora = emisoraDemo(uuid: 'fav-sync', nombre: 'Sync'); diff --git a/test/helpers/fakes.dart b/test/helpers/fakes.dart index bb3dd03..bee3248 100644 --- a/test/helpers/fakes.dart +++ b/test/helpers/fakes.dart @@ -147,7 +147,7 @@ class FakeServicioRadio extends ServicioRadio { error is Exception ? error : Exception(error.toString()); @override - Future> obtenerPopulares({int limit = 30}) async { + Future> obtenerPopulares({int limit = 30, int offset = 0}) async { final llamada = obtenerPopularesCalls++; if (llamada < _erroresPopularesPorLlamada.length) { throw _normalizarError(_erroresPopularesPorLlamada[llamada]); @@ -177,8 +177,9 @@ class FakeServicioRadio extends ServicioRadio { String? idioma, String? tag, int limit = 30, + int offset = 0, }) async { - return _busqueda.take(limit).toList(); + return _busqueda.skip(offset).take(limit).toList(); } @override