2 Commits

Author SHA1 Message Date
agent-arq
a739fb4162 docs(arq): revisión arquitectura Fase 1 — stack aprobado, ajustes menores
Some checks failed
Flutter CI/CD — PluriWave / Test + Build (pull_request) Has been cancelled
2026-04-04 16:39:37 +02:00
agent-arq
4a83019f40 feat(ci): workflow Gitea Actions Flutter — test + build APK/AAB + Telegram 2026-04-04 16:35:40 +02:00
35 changed files with 150 additions and 4038 deletions

View File

@@ -1,52 +0,0 @@
# Changelog — PluriWave
## [0.5.0] — 2026-04-04
### Añadido
- **VisualizadorAudio** — visualizador de barras animadas en `PantallaReproductor`. 24 barras verticales con movimiento orgánico pseudo-aleatorio (combinación de ondas seno con fases distintas). Se activa al reproducir y decae suavemente al parar. Sin FFT real ni permisos de micrófono — animación simulada visualmente equivalente a las apps de streaming.
- **IndicadorReproduccion** — versión compacta de 3 barras para el `MiniReproductor`. Reemplaza el icono estático de radio y pulsa mientras hay audio activo.
## [0.4.0] — 2026-04-04
### Añadido
- **PantallaReproductor** — pantalla completa del reproductor. Accesible tocando MiniReproductor o cualquier emisora. Incluye: artwork/logo grande con sombra animada al reproducir, nombre + chips info (país, idioma), codec/bitrate, controles play/pause/stop con indicador "en vivo", botón favorito (toggle), widget de timer (iniciar/cancelar desde la pantalla), animación de entrada slide-up. Transición pageRoute desde cualquier pantalla.
- **PantallaAjustes** — pantalla de ajustes básica (tab nuevo en NavigationBar). Muestra estado del sistema (filtro emisoras, audio background), conteo de favoritos, preview de features próximas (Export/Import, radio personalizada, ecualizador).
- **MiniReproductor** — ahora es tappable: toca la barra para abrir PantallaReproductor.
- **NavigationBar** — añadido tab "Ajustes" (4 destinos: Inicio/Buscar/Favoritos/Ajustes).
## [0.3.0] — 2026-04-04
### Fixes (prioridad alta — petición WhikY)
- **Audio en background** — `ServicioAudio` refactorizado para delegar toda la reproducción a `PluriWaveAudioHandler` (audio_service). La notificación foreground de Android mantiene el audio vivo al apagar pantalla. Handler inicializado en `main.dart` con `AudioService.init()` y registrado globalmente. `onTaskRemoved` libera recursos al cerrar la app. `mediaItem` propagado con nombre, artista y artwork de la emisora.
- **Filtrar emisoras rotas** — `ServicioRadio` añade `lastcheckok=1` en todas las peticiones a la API. Solo se devuelven emisoras verificadas como funcionales por Radio Browser.
- **Errores como SnackBar** — `EstadoRadio` emite errores de reproducción y búsqueda por `errorStream` (StreamController broadcast). `_PaginaPrincipalState.didChangeDependencies` suscribe al stream y muestra `SnackBar` flotante de 3 segundos. Los errores de carga de lista siguen como banner inline (no bloquean la UI).
- **Icono de app** — Generado con Stable Diffusion XL: diseño morado, ondas de radio blancas, estilo Material You. Todos los tamaños Android generados (mdpi/hdpi/xhdpi/xxhdpi/xxxhdpi, 48-192px). `ic_launcher_round` añadido. `android:roundIcon` en AndroidManifest.
### Ficheros modificados
| Fichero | Cambio |
|---|---|
| `lib/main.dart` | `AudioService.init()` + `registrarHandler()` |
| `lib/servicios/servicio_audio.dart` | Arquitectura background completa |
| `lib/servicios/servicio_radio.dart` | `lastcheckok=1` en todas las peticiones |
| `lib/estado/estado_radio.dart` | `errorStream` en lugar de campo `_error` |
| `lib/app.dart` | Listener `errorStream` → SnackBar + theme SnackBar |
| `android/app/src/main/AndroidManifest.xml` | `roundIcon` |
| `android/app/src/main/res/mipmap-*/` | Iconos generados (5 densidades) |
## [0.2.0] — 2026-04-04
### Añadido
- **CI/CD Gitea Actions** — workflow `.gitea/workflows/ci.yml` para el runner `macmini-flutter`. Jobs en secuencia: `flutter pub get``flutter test``flutter build apk --release``flutter build appbundle --release`. APK y AAB subidos como artifacts con el SHA del commit en el nombre (`pluriwave-apk-<sha>`, `pluriwave-aab-<sha>`). Notificación Telegram al finalizar: ✅ éxito con commit y rama, ❌ fallo con enlace al log. Activado en push a `main` y PRs contra `main`.
- **`ARQ-REVISION-F1.md`** — revisión de arquitectura del stack Flutter. Veredicto: aprobado. Sin conflictos de dependencias (`audio_session` compartido entre `just_audio` y `audio_service` sin colisión; `rxdart` sin conflicto). Todas las licencias OSI-approved (MIT, Apache-2.0, BSD). Ajustes pendientes: actualizar `just_audio` a ^0.10.0 con Flutter ≥3.27.0, signing real para Play Store, `google_mobile_ads` comentado hasta tener Ad Unit IDs.
### Notas técnicas
- **Signing**: `build.gradle.kts` usa clave debug para release (TODO preexistente). Válido para CI interno y testing. Play Store requiere keystore como secret en Gitea.
- **Secrets necesarios**: `TELEGRAM_BOT_TOKEN` y `TELEGRAM_CHAT_ID` (Settings → Secrets del repo en Gitea).
### Ficheros añadidos
| Fichero | Descripción |
|---|---|
| `.gitea/workflows/ci.yml` | Workflow CI/CD Flutter completo (+66 líneas) |
| `ARQ-REVISION-F1.md` | Revisión arquitectura F1 — stack, licencias, ajustes (+143 líneas) |

View File

@@ -1,17 +1,17 @@
# PluriWave
# 📻 PluriWave
Radio mundial con ecualizador personalizable, reconocimiento de canciones y UI premium.
## Features
- **+53.000 emisoras** de 238 países (Radio Browser API)
- **Ecualizador por emisora** — guarda tu preset favorito para cada radio
- **Reconocimiento de canciones** — "¿Qué suena?" sin salir de la app
- **Timer de auto-apagado** — perfecto para dormir
- **Reproducción en segundo plano** — sigue sonando con la pantalla apagada
- **Favoritos** — acceso rápido a emisoras preferidas
- **Compartir** — envía emisoras a tus amigos
- **UI premium** — Material You, visualizador de audio, animaciones fluidas
- 🌍 **+53.000 emisoras** de 238 países (Radio Browser API)
- 🎛️ **Ecualizador por emisora** — guarda tu preset favorito para cada radio
- 🎵 **Reconocimiento de canciones** — "¿Qué suena?" sin salir de la app
- **Timer de auto-apagado** — perfecto para dormir
- 🔊 **Reproducción en segundo plano** — sigue sonando con la pantalla apagada
- **Favoritos** — accede rápido a tus emisoras preferidas
- 📤 **Compartir** — envía emisoras a tus amigos
- 🎨 **UI premium** — Material You, visualizador de audio, animaciones fluidas
## Monetización
@@ -22,31 +22,13 @@ Radio mundial con ecualizador personalizable, reconocimiento de canciones y UI p
## Stack
- **Frontend**: Flutter (Android + iOS)
- **Radio API**: Radio Browser (gratis, +53K emisoras)
- **Radio API**: [Radio Browser](https://api.radio-browser.info/) (gratis, +53K emisoras)
- **Audio**: just_audio + audio_service
- **Ecualizador**: just_audio equalizer (Android nativo)
- **Reconocimiento**: AudD API (1000 req/mes free)
- **Ads**: Google AdMob
- **Compras**: in_app_purchase
## CI/CD
Workflow Gitea Actions en `.gitea/workflows/ci.yml`, runner `macmini-flutter`.
**Jobs:** `flutter pub get``flutter test``build apk --release``build appbundle --release`
**Artifacts:** APK y AAB guardados en Gitea con nombre `pluriwave-apk-<sha>` / `pluriwave-aab-<sha>`.
**Notificaciones:** Telegram al completar (éxito ✅ / fallo ❌).
**Secrets necesarios en el repo:**
| Secret | Uso |
|---|---|
| `TELEGRAM_BOT_TOKEN` | Notificaciones CI |
| `TELEGRAM_CHAT_ID` | Canal de destino |
> **Signing**: build de release usa clave debug (válido para CI interno). Para Play Store se requiere keystore como secret adicional.
## Desarrollador
FreeTimeLab — [freetimelab.es](https://freetimelab.es)
@@ -54,4 +36,3 @@ FreeTimeLab — [freetimelab.es](https://freetimelab.es)
## Licencia
MIT

View File

@@ -1,15 +1,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Permisos requeridos para streaming de audio -->
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<application
android:label="PluriWave"
android:label="pluriwave"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round">
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
@@ -19,6 +12,10 @@
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
@@ -28,30 +25,17 @@
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Servicio de audio en background (audio_service) -->
<service
android:name="com.ryanheise.audioservice.AudioService"
android:foregroundServiceType="mediaPlayback"
android:exported="true">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService"/>
</intent-filter>
</service>
<!-- Receptor de controles de media (auriculares, notificación) -->
<receiver
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON"/>
</intent-filter>
</receiver>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>

View File

@@ -1,5 +1,5 @@
package es.freetimelab.pluriwave
import com.ryanheise.audioservice.AudioServiceActivity
import io.flutter.embedding.android.FlutterActivity
class MainActivity : AudioServiceActivity()
class MainActivity : FlutterActivity()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 656 B

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 656 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 452 B

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 452 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 838 B

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 838 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

View File

@@ -1,196 +0,0 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:provider/provider.dart';
import 'estado/estado_radio.dart';
import 'pantallas/pantalla_inicio.dart';
import 'pantallas/pantalla_buscar.dart';
import 'pantallas/pantalla_favoritos.dart';
import 'pantallas/pantalla_ajustes.dart';
import 'widgets/mini_reproductor.dart';
class PluriWaveApp extends StatelessWidget {
const PluriWaveApp({super.key});
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (_) => EstadoRadio(),
child: MaterialApp(
title: 'PluriWave',
debugShowCheckedModeBanner: false,
theme: _buildTheme(Brightness.dark),
darkTheme: _buildTheme(Brightness.dark),
themeMode: ThemeMode.dark,
home: const _PaginaPrincipal(),
),
);
}
ThemeData _buildTheme(Brightness brightness) {
final colorScheme = ColorScheme.fromSeed(
seedColor: const Color(0xFF6750A4),
brightness: brightness,
);
return ThemeData(
useMaterial3: true,
colorScheme: colorScheme,
textTheme: GoogleFonts.interTextTheme(
ThemeData(brightness: brightness).textTheme,
),
cardTheme: CardThemeData(
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
color: colorScheme.surfaceContainerLow,
),
snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
);
}
}
class _PaginaPrincipal extends StatefulWidget {
const _PaginaPrincipal();
@override
State<_PaginaPrincipal> createState() => _PaginaPrincipalState();
}
class _PaginaPrincipalState extends State<_PaginaPrincipal> {
int _indice = 0;
static const _paginas = [
PantallaInicio(),
PantallaBuscar(),
PantallaFavoritos(),
PantallaAjustes(),
];
static const _destinos = [
NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home),
label: 'Inicio',
),
NavigationDestination(
icon: Icon(Icons.search_outlined),
selectedIcon: Icon(Icons.search),
label: 'Buscar',
),
NavigationDestination(
icon: Icon(Icons.favorite_outline),
selectedIcon: Icon(Icons.favorite),
label: 'Favoritos',
),
NavigationDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: 'Ajustes',
),
];
@override
void didChangeDependencies() {
super.didChangeDependencies();
context.read<EstadoRadio>().errorStream.listen((msg) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(msg),
duration: const Duration(seconds: 3),
action: SnackBarAction(label: 'OK', onPressed: () {}),
),
);
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: _indice == 3
? null // PantallaAjustes tiene su propio AppBar
: AppBar(
title: const Text('PluriWave'),
actions: [
IconButton(
icon: const Icon(Icons.bedtime_outlined),
tooltip: 'Timer de sueño',
onPressed: () => _mostrarTimerDialog(context),
),
],
),
body: _paginas[_indice],
bottomNavigationBar: Column(
mainAxisSize: MainAxisSize.min,
children: [
const MiniReproductor(),
NavigationBar(
selectedIndex: _indice,
onDestinationSelected: (i) => setState(() => _indice = i),
destinations: _destinos,
),
],
),
);
}
void _mostrarTimerDialog(BuildContext context) {
final estado = context.read<EstadoRadio>();
showModalBottomSheet(
context: context,
builder: (ctx) => SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Timer de sueño', style: Theme.of(ctx).textTheme.titleLarge),
const SizedBox(height: 16),
if (estado.timer.activo)
StreamBuilder<Duration>(
stream: estado.timer.tiempoRestanteStream,
builder: (ctx, snap) {
final t = snap.data ?? Duration.zero;
final h = t.inHours;
final m = t.inMinutes.remainder(60).toString().padLeft(2, '0');
final s = t.inSeconds.remainder(60).toString().padLeft(2, '0');
return Column(
children: [
Text(
'${h > 0 ? "${h}h " : ""}${m}m ${s}s',
style: Theme.of(ctx).textTheme.headlineMedium,
),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: () {
estado.cancelarTimer();
Navigator.pop(ctx);
},
child: const Text('Cancelar timer'),
),
],
);
},
)
else
Wrap(
spacing: 8,
children: [15, 30, 60, 90]
.map((min) => ActionChip(
label: Text('$min min'),
onPressed: () {
estado.iniciarTimer(min);
Navigator.pop(ctx);
},
))
.toList(),
),
],
),
),
),
);
}
}

View File

@@ -1,270 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import '../modelos/emisora.dart';
import '../modelos/preset_ecualizador.dart';
import '../servicios/servicio_audio.dart';
import '../servicios/servicio_favoritos.dart';
import '../servicios/servicio_radio.dart';
import '../servicios/servicio_timer.dart';
/// Estado global de la app con ChangeNotifier (Provider).
class EstadoRadio extends ChangeNotifier {
final ServicioAudio audio = ServicioAudio();
final ServicioFavoritos favoritos = ServicioFavoritos();
final ServicioRadio radio = ServicioRadio();
late final ServicioTimer timer;
// Errores de reproducción → SnackBar
final _errorController = StreamController<String>.broadcast();
Stream<String> get errorStream => _errorController.stream;
List<Emisora> _populares = [];
List<Emisora> _tendencias = [];
List<Emisora> _resultadosBusqueda = [];
List<Emisora> _listafavoritos = [];
List<Emisora> _emisorasCustom = [];
// Presets EQ guardados por uuid de emisora
final Map<String, PresetEcualizador> _presetsEmisoraMap = {};
PresetEcualizador _presetActual = PresetEcualizador.flat;
bool _cargandoPopulares = false;
bool _cargandoBusqueda = false;
String? _errorCarga;
EstadoRadio() {
timer = ServicioTimer(audio);
_init();
}
List<Emisora> get populares => _populares;
List<Emisora> get tendencias => _tendencias;
List<Emisora> get resultadosBusqueda => _resultadosBusqueda;
List<Emisora> get listaFavoritos => _listafavoritos;
List<Emisora> get emisorasCustom => _emisorasCustom;
bool get cargandoPopulares => _cargandoPopulares;
bool get cargandoBusqueda => _cargandoBusqueda;
String? get error => _errorCarga;
Emisora? get emisoraActual => audio.emisoraActual;
Stream<EstadoReproduccion> get estadoStream => audio.estadoStream;
PresetEcualizador get presetEcualizador => _presetActual;
bool get ecualizadorDisponible => audio.ecualizadorDisponible;
Future<void> _init() async {
await Future.wait([
cargarPopulares(),
cargarFavoritos(),
_cargarEmisoresCustom(),
]);
}
Future<void> cargarPopulares() async {
_cargandoPopulares = true;
_errorCarga = null;
notifyListeners();
try {
final results = await Future.wait([
radio.obtenerPopulares(limit: 30),
radio.obtenerTendencias(limit: 20),
]);
_populares = results[0];
_tendencias = results[1];
} catch (e) {
_errorCarga = 'Sin conexión a la API de radio';
} finally {
_cargandoPopulares = false;
notifyListeners();
}
}
Future<void> cargarFavoritos() async {
_listafavoritos = await favoritos.obtenerTodos();
notifyListeners();
}
Future<void> buscar({
String? nombre,
String? pais,
String? idioma,
String? tag,
}) async {
_cargandoBusqueda = true;
_resultadosBusqueda = [];
notifyListeners();
try {
_resultadosBusqueda = await radio.buscar(
nombre: nombre,
pais: pais,
idioma: idioma,
tag: tag,
);
} catch (e) {
_errorController.add('Error en la búsqueda. Comprueba tu conexión.');
} finally {
_cargandoBusqueda = false;
notifyListeners();
}
}
Future<void> reproducir(Emisora emisora) async {
try {
await audio.reproducir(emisora);
radio.registrarClick(emisora.uuid); // fire & forget
// Restaurar preset del ecualizador de esta emisora
final preset = _presetsEmisoraMap[emisora.uuid] ?? PresetEcualizador.flat;
await cambiarPresetEcualizador(preset, guardPorEmisora: false);
notifyListeners();
} catch (e) {
_errorController.add('No se puede reproducir "${emisora.nombre}"');
}
}
Future<void> togglePlay() async {
await audio.togglePlay();
notifyListeners();
}
Future<bool> toggleFavorito(Emisora emisora) async {
final esFav = await favoritos.toggleFavorito(emisora);
await cargarFavoritos();
notifyListeners();
return esFav;
}
Future<bool> esFavorito(String uuid) => favoritos.esFavorito(uuid);
// ── Ecualizador ──────────────────────────────────────────────────────────
Future<void> cambiarPresetEcualizador(
PresetEcualizador preset, {
bool guardPorEmisora = true,
}) async {
_presetActual = preset;
await audio.aplicarPreset(preset);
if (guardPorEmisora && emisoraActual != null) {
_presetsEmisoraMap[emisoraActual!.uuid] = preset;
}
notifyListeners();
}
Future<void> cambiarBandaEcualizador(int index, double db) async {
final bandas = List<double>.from(_presetActual.bandas);
if (index >= 0 && index < bandas.length) bandas[index] = db;
_presetActual = PresetEcualizador(nombre: 'Personalizado', bandas: bandas);
await audio.setBanda(index, db);
if (emisoraActual != null) {
_presetsEmisoraMap[emisoraActual!.uuid] = _presetActual;
}
notifyListeners();
}
// ── Emisoras personalizadas ───────────────────────────────────────────────
Future<File> _archivoCustom() async {
final dir = await getApplicationDocumentsDirectory();
return File('${dir.path}/emisoras_custom.json');
}
Future<void> _cargarEmisoresCustom() async {
try {
final f = await _archivoCustom();
if (!await f.exists()) return;
final data = jsonDecode(await f.readAsString()) as List;
_emisorasCustom = data
.map((e) => Emisora.fromMap(Map<String, dynamic>.from(e as Map)))
.toList();
} catch (_) {
_emisorasCustom = [];
}
notifyListeners();
}
Future<void> _guardarEmisoresCustom() async {
final f = await _archivoCustom();
await f.writeAsString(jsonEncode(_emisorasCustom.map((e) => e.toMap()).toList()));
}
Future<void> agregarEmitoraCustom(Emisora emisora) async {
_emisorasCustom.removeWhere((e) => e.uuid == emisora.uuid);
_emisorasCustom.add(emisora);
await _guardarEmisoresCustom();
notifyListeners();
}
Future<void> eliminarEmitoraCustom(String uuid) async {
_emisorasCustom.removeWhere((e) => e.uuid == uuid);
await _guardarEmisoresCustom();
notifyListeners();
}
// ── Export / Import ───────────────────────────────────────────────────────
/// Genera el JSON de toda la configuración.
Future<Map<String, dynamic>> exportarConfig() async {
final favs = await favoritos.obtenerTodos();
return {
'version': 1,
'exportedAt': DateTime.now().toIso8601String(),
'favoritos': favs.map((e) => e.toMap()).toList(),
'emisorasCustom': _emisorasCustom.map((e) => e.toMap()).toList(),
'presetsEcualizador': _presetsEmisoraMap.map(
(uuid, preset) => MapEntry(uuid, preset.toJson()),
),
};
}
/// Importa configuración desde un JSON exportado previamente.
Future<void> importarConfig(Map<String, dynamic> data) async {
final version = data['version'] as int? ?? 1;
if (version != 1) throw Exception('Versión de configuración no compatible');
// Importar favoritos
final favRaw = data['favoritos'] as List? ?? [];
for (final raw in favRaw) {
final emisora = Emisora.fromMap(Map<String, dynamic>.from(raw as Map));
await favoritos.agregar(emisora);
}
// Importar emisoras custom
final customRaw = data['emisorasCustom'] as List? ?? [];
_emisorasCustom = customRaw
.map((e) => Emisora.fromMap(Map<String, dynamic>.from(e as Map)))
.toList();
await _guardarEmisoresCustom();
// Importar presets EQ
final presetsRaw = data['presetsEcualizador'] as Map? ?? {};
_presetsEmisoraMap.clear();
presetsRaw.forEach((uuid, presetJson) {
_presetsEmisoraMap[uuid as String] =
PresetEcualizador.desdeJson(Map<String, dynamic>.from(presetJson as Map));
});
await cargarFavoritos();
notifyListeners();
}
// ── Timer ─────────────────────────────────────────────────────────────────
void iniciarTimer(int minutos) {
timer.iniciar(minutos);
notifyListeners();
}
void cancelarTimer() {
timer.cancelar();
notifyListeners();
}
@override
void dispose() {
_errorController.close();
audio.dispose();
timer.dispose();
super.dispose();
}
}

View File

@@ -1,22 +1,122 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'app.dart';
import 'servicios/servicio_audio.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final handler = await AudioService.init(
builder: () => PluriWaveAudioHandler(),
config: const AudioServiceConfig(
androidNotificationChannelId: 'es.freetimelab.pluriwave.audio',
androidNotificationChannelName: 'PluriWave Radio',
androidNotificationOngoing: true,
androidStopForegroundOnPause: true,
notificationColor: Color(0xFF6750A4),
),
);
registrarHandler(handler);
runApp(const PluriWaveApp());
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
// This is the theme of your application.
//
// TRY THIS: Try running your application with "flutter run". You'll see
// the application has a purple toolbar. Then, without quitting the app,
// try changing the seedColor in the colorScheme below to Colors.green
// and then invoke "hot reload" (save your changes or press the "hot
// reload" button in a Flutter-supported IDE, or press "r" if you used
// the command line to start the app).
//
// Notice that the counter didn't reset back to zero; the application
// state is not lost during the reload. To reset the state, use hot
// restart instead.
//
// This works for code too, not just values: Most code changes can be
// tested with just a hot reload.
colorScheme: .fromSeed(seedColor: Colors.deepPurple),
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
// This widget is the home page of your application. It is stateful, meaning
// that it has a State object (defined below) that contains fields that affect
// how it looks.
// This class is the configuration for the state. It holds the values (in this
// case the title) provided by the parent (in this case the App widget) and
// used by the build method of the State. Fields in a Widget subclass are
// always marked "final".
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
void _incrementCounter() {
setState(() {
// This call to setState tells the Flutter framework that something has
// changed in this State, which causes it to rerun the build method below
// so that the display can reflect the updated values. If we changed
// _counter without calling setState(), then the build method would not be
// called again, and so nothing would appear to happen.
_counter++;
});
}
@override
Widget build(BuildContext context) {
// This method is rerun every time setState is called, for instance as done
// by the _incrementCounter method above.
//
// The Flutter framework has been optimized to make rerunning build methods
// fast, so that you can just rebuild anything that needs updating rather
// than having to individually change instances of widgets.
return Scaffold(
appBar: AppBar(
// TRY THIS: Try changing the color here to a specific color (to
// Colors.amber, perhaps?) and trigger a hot reload to see the AppBar
// change color while the other colors stay the same.
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
// Here we take the value from the MyHomePage object that was created by
// the App.build method, and use it to set our appbar title.
title: Text(widget.title),
),
body: Center(
// Center is a layout widget. It takes a single child and positions it
// in the middle of the parent.
child: Column(
// Column is also a layout widget. It takes a list of children and
// arranges them vertically. By default, it sizes itself to fit its
// children horizontally, and tries to be as tall as its parent.
//
// Column has various properties to control how it sizes itself and
// how it positions its children. Here we use mainAxisAlignment to
// center the children vertically; the main axis here is the vertical
// axis because Columns are vertical (the cross axis would be
// horizontal).
//
// TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint"
// action in the IDE, or press "p" in the console), to see the
// wireframe for each widget.
mainAxisAlignment: .center,
children: [
const Text('You have pushed the button this many times:'),
Text(
'$_counter',
style: Theme.of(context).textTheme.headlineMedium,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
}

View File

@@ -1,149 +0,0 @@
/// Modelo de datos de una emisora de radio.
///
/// Unifica los campos de la Radio Browser API con los de la tabla SQLite
/// de favoritos. Los campos opcionales pueden ser null cuando la emisora
/// no dispone de esa información.
class Emisora {
final int? id;
final String uuid;
final String nombre;
final String url;
final String? favicon;
final String? pais;
final String? codigoPais; // ISO 3166-1 alpha-2
final String? idioma;
final String? tags;
final String? codec;
final int? bitrate;
final int votes;
final int clickcount;
final int orden;
const Emisora({
this.id,
required this.uuid,
required this.nombre,
required this.url,
this.favicon,
this.pais,
this.codigoPais,
this.idioma,
this.tags,
this.codec,
this.bitrate,
this.votes = 0,
this.clickcount = 0,
this.orden = 0,
});
/// Construye una [Emisora] desde la respuesta JSON de Radio Browser API.
factory Emisora.fromApi(Map<String, dynamic> json) {
return Emisora(
uuid: json['stationuuid'] as String? ?? '',
nombre: json['name'] as String? ?? 'Sin nombre',
url: json['url_resolved'] as String? ?? json['url'] as String? ?? '',
favicon: _nonEmpty(json['favicon'] as String?),
pais: _nonEmpty(json['country'] as String?),
codigoPais: _nonEmpty(json['countrycode'] as String?),
idioma: _nonEmpty(json['language'] as String?),
tags: _nonEmpty(json['tags'] as String?),
codec: _nonEmpty(json['codec'] as String?),
bitrate: json['bitrate'] as int?,
votes: json['votes'] as int? ?? 0,
clickcount: json['clickcount'] as int? ?? 0,
);
}
/// Construye una [Emisora] desde una fila de la tabla `favoritos`.
factory Emisora.fromMap(Map<String, dynamic> map) {
return Emisora(
id: map['id'] as int?,
uuid: map['uuid'] as String,
nombre: map['nombre'] as String,
url: map['url'] as String,
favicon: map['favicon'] as String?,
pais: map['pais'] as String?,
codigoPais: map['codigo_pais'] as String?,
idioma: map['idioma'] as String?,
tags: map['tags'] as String?,
codec: map['codec'] as String?,
bitrate: map['bitrate'] as int?,
votes: map['votes'] as int? ?? 0,
clickcount: map['clickcount'] as int? ?? 0,
orden: map['orden'] as int? ?? 0,
);
}
/// Serializa para inserción/actualización en SQLite.
Map<String, dynamic> toMap() {
return {
'uuid': uuid,
'nombre': nombre,
'url': url,
'favicon': favicon,
'pais': pais,
'codigo_pais': codigoPais,
'idioma': idioma,
'tags': tags,
'codec': codec,
'bitrate': bitrate,
'votes': votes,
'clickcount': clickcount,
'orden': orden,
};
}
Emisora copyWith({
int? id,
String? uuid,
String? nombre,
String? url,
String? favicon,
String? pais,
String? codigoPais,
String? idioma,
String? tags,
String? codec,
int? bitrate,
int? votes,
int? clickcount,
int? orden,
}) {
return Emisora(
id: id ?? this.id,
uuid: uuid ?? this.uuid,
nombre: nombre ?? this.nombre,
url: url ?? this.url,
favicon: favicon ?? this.favicon,
pais: pais ?? this.pais,
codigoPais: codigoPais ?? this.codigoPais,
idioma: idioma ?? this.idioma,
tags: tags ?? this.tags,
codec: codec ?? this.codec,
bitrate: bitrate ?? this.bitrate,
votes: votes ?? this.votes,
clickcount: clickcount ?? this.clickcount,
orden: orden ?? this.orden,
);
}
/// Lista de géneros/tags como lista limpia.
List<String> get generos {
if (tags == null || tags!.isEmpty) return [];
return tags!.split(',').map((t) => t.trim()).where((t) => t.isNotEmpty).toList();
}
static String? _nonEmpty(String? s) =>
(s == null || s.trim().isEmpty) ? null : s.trim();
@override
String toString() => 'Emisora(uuid: $uuid, nombre: $nombre)';
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Emisora && runtimeType == other.runtimeType && uuid == other.uuid;
@override
int get hashCode => uuid.hashCode;
}

View File

@@ -1,29 +0,0 @@
/// Modelo de preset de ecualizador.
/// 5 bandas: 60Hz, 250Hz, 1kHz, 4kHz, 16kHz
class PresetEcualizador {
final String nombre;
final List<double> bandas; // 5 valores entre -12.0 y +12.0 dB
const PresetEcualizador({required this.nombre, required this.bandas})
: assert(bandas.length == 5);
static final flat = PresetEcualizador(nombre: 'Flat', bandas: [0.0, 0.0, 0.0, 0.0, 0.0]);
static final rock = PresetEcualizador(nombre: 'Rock', bandas: [2.0, 1.0, -1.0, 2.0, 3.0]);
static final pop = PresetEcualizador(nombre: 'Pop', bandas: [1.0, 1.5, 0.5, 1.0, 1.5]);
static final bassBoost = PresetEcualizador(nombre: 'Bass Boost', bandas: [5.0, 3.0, -1.0, 0.5, 0.0]);
static final jazz = PresetEcualizador(nombre: 'Jazz', bandas: [3.0, -1.0, -1.5, 2.0, 4.0]);
static final voz = PresetEcualizador(nombre: 'Voz', bandas: [-2.0, -1.0, 2.0, 3.0, 1.0]);
static final presets = [flat, rock, pop, bassBoost, jazz, voz];
factory PresetEcualizador.desdeJson(Map<String, dynamic> json) {
final raw = (json['bandas'] as List?)?.map((e) => (e as num).toDouble()).toList() ?? <double>[];
final bandas = List<double>.generate(5, (i) => i < raw.length ? raw[i] : 0.0);
return PresetEcualizador(nombre: json['nombre'] as String? ?? 'Personalizado', bandas: bandas);
}
Map<String, dynamic> toJson() => {'nombre': nombre, 'bandas': bandas};
PresetEcualizador copyWithBandas(List<double> bandas) =>
PresetEcualizador(nombre: 'Personalizado', bandas: bandas);
}

View File

@@ -1,401 +0,0 @@
import 'dart:convert';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart' show Share, XFile;
import 'package:path_provider/path_provider.dart';
import 'package:uuid/uuid.dart';
import '../estado/estado_radio.dart';
import '../modelos/emisora.dart';
import '../modelos/preset_ecualizador.dart';
import '../widgets/ecualizador_widget.dart';
class PantallaAjustes extends StatelessWidget {
const PantallaAjustes({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Ajustes')),
body: ListView(
children: const [
_SeccionEcualizador(),
Divider(),
_SeccionEmisoras(),
Divider(),
_SeccionBackup(),
Divider(),
_SeccionInfo(),
],
),
);
}
}
// ── Sección Ecualizador ───────────────────────────────────────────────────────
class _SeccionEcualizador extends StatelessWidget {
const _SeccionEcualizador();
@override
Widget build(BuildContext context) {
return Consumer<EstadoRadio>(
builder: (ctx, estado, _) {
final disponible = estado.ecualizadorDisponible;
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
const Icon(Icons.equalizer),
const SizedBox(width: 12),
Text('Ecualizador', style: Theme.of(ctx).textTheme.titleMedium),
const Spacer(),
if (!disponible)
Chip(
label: const Text('Reproduce una emisora para activar'),
visualDensity: VisualDensity.compact,
),
],
),
if (disponible) ...[
const SizedBox(height: 8),
PresetsEcualizadorWidget(
presetActual: estado.presetEcualizador,
onSeleccionar: (p) => estado.cambiarPresetEcualizador(p),
),
const SizedBox(height: 12),
EcualizadorWidget(
preset: estado.presetEcualizador,
onCambio: (p) {
for (int i = 0; i < p.bandas.length; i++) {
estado.cambiarBandaEcualizador(i, p.bandas[i]);
}
},
),
],
],
),
);
},
);
}
}
// ── Sección Emisoras personalizadas ──────────────────────────────────────────
class _SeccionEmisoras extends StatelessWidget {
const _SeccionEmisoras();
@override
Widget build(BuildContext context) {
final estado = context.watch<EstadoRadio>();
final custom = estado.emisorasCustom;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Row(
children: [
const Icon(Icons.add_circle_outline),
const SizedBox(width: 12),
Text('Emisoras personalizadas',
style: Theme.of(context).textTheme.titleMedium),
const Spacer(),
TextButton.icon(
icon: const Icon(Icons.add),
label: const Text('Añadir'),
onPressed: () => _mostrarFormularioAnadir(context),
),
],
),
),
if (custom.isEmpty)
const Padding(
padding: EdgeInsets.fromLTRB(16, 4, 16, 12),
child: Text('No hay emisoras personalizadas.',
style: TextStyle(color: Colors.grey)),
)
else
for (final emisora in custom)
ListTile(
leading: const Icon(Icons.radio),
title: Text(emisora.nombre),
subtitle: Text(emisora.url, maxLines: 1, overflow: TextOverflow.ellipsis),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.play_arrow),
tooltip: 'Reproducir',
onPressed: () => context.read<EstadoRadio>().reproducir(emisora),
),
IconButton(
icon: const Icon(Icons.delete_outline),
tooltip: 'Eliminar',
onPressed: () => context.read<EstadoRadio>().eliminarEmitoraCustom(emisora.uuid),
),
],
),
),
],
);
}
Future<void> _mostrarFormularioAnadir(BuildContext context) async {
await showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (ctx) => const _FormularioEmisora(),
);
}
}
class _FormularioEmisora extends StatefulWidget {
const _FormularioEmisora();
@override
State<_FormularioEmisora> createState() => _FormularioEmisoraState();
}
class _FormularioEmisoraState extends State<_FormularioEmisora> {
final _formKey = GlobalKey<FormState>();
final _nombreCtrl = TextEditingController();
final _urlCtrl = TextEditingController();
final _paisCtrl = TextEditingController();
bool _guardando = false;
@override
void dispose() {
_nombreCtrl.dispose();
_urlCtrl.dispose();
_paisCtrl.dispose();
super.dispose();
}
Future<void> _guardar() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _guardando = true);
final emisora = Emisora(
uuid: const Uuid().v4(),
nombre: _nombreCtrl.text.trim(),
url: _urlCtrl.text.trim(),
pais: _paisCtrl.text.trim().isEmpty ? null : _paisCtrl.text.trim(),
);
await context.read<EstadoRadio>().agregarEmitoraCustom(emisora);
if (mounted) Navigator.pop(context);
}
@override
Widget build(BuildContext context) {
final bottom = MediaQuery.of(context).viewInsets.bottom;
return Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 16 + bottom),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Añadir emisora', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 16),
TextFormField(
controller: _nombreCtrl,
decoration: const InputDecoration(labelText: 'Nombre *', border: OutlineInputBorder()),
validator: (v) => v == null || v.trim().isEmpty ? 'Campo obligatorio' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _urlCtrl,
decoration: const InputDecoration(
labelText: 'URL del stream *',
hintText: 'http://stream.ejemplo.com:8000/radio',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.url,
validator: (v) {
if (v == null || v.trim().isEmpty) return 'Campo obligatorio';
final uri = Uri.tryParse(v.trim());
if (uri == null || !uri.hasScheme) return 'URL no válida';
return null;
},
),
const SizedBox(height: 12),
TextFormField(
controller: _paisCtrl,
decoration: const InputDecoration(
labelText: 'País (opcional)',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 20),
FilledButton(
onPressed: _guardando ? null : _guardar,
child: _guardando
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Guardar emisora'),
),
],
),
),
);
}
}
// ── Sección Backup ────────────────────────────────────────────────────────────
class _SeccionBackup extends StatelessWidget {
const _SeccionBackup();
Future<void> _exportar(BuildContext context) async {
try {
final estado = context.read<EstadoRadio>();
final config = await estado.exportarConfig();
final json = const JsonEncoder.withIndent(' ').convert(config);
final dir = await getTemporaryDirectory();
final file = File('${dir.path}/pluriwave-backup.json');
await file.writeAsString(json);
await Share.shareXFiles(
[XFile(file.path)],
subject: 'PluriWave — copia de seguridad',
text: 'Configuración de PluriWave exportada el ${DateTime.now().toLocal()}',
);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error al exportar: $e')),
);
}
}
}
Future<void> _importar(BuildContext context) async {
try {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['json'],
);
if (result == null || result.files.single.path == null) return;
final file = File(result.files.single.path!);
final json = jsonDecode(await file.readAsString()) as Map<String, dynamic>;
if (context.mounted) {
final confirmar = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Importar configuración'),
content: const Text(
'Esto añadirá los favoritos, emisoras y presets del fichero. '
'¿Continuar?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancelar')),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Importar')),
],
),
);
if (confirmar != true) return;
if (context.mounted) {
await context.read<EstadoRadio>().importarConfig(json);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Configuración importada correctamente')),
);
}
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error al importar: $e')),
);
}
}
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Row(
children: [
const Icon(Icons.backup_outlined),
const SizedBox(width: 12),
Text('Copia de seguridad',
style: Theme.of(context).textTheme.titleMedium),
],
),
),
ListTile(
leading: const Icon(Icons.upload_outlined),
title: const Text('Exportar configuración'),
subtitle: const Text('Favoritos, emisoras custom y presets de EQ'),
onTap: () => _exportar(context),
),
ListTile(
leading: const Icon(Icons.download_outlined),
title: const Text('Importar configuración'),
subtitle: const Text('Restaurar desde un fichero de copia de seguridad'),
onTap: () => _importar(context),
),
],
);
}
}
// ── Sección Info ──────────────────────────────────────────────────────────────
class _SeccionInfo extends StatelessWidget {
const _SeccionInfo();
@override
Widget build(BuildContext context) {
return Consumer<EstadoRadio>(
builder: (ctx, estado, _) => Column(
children: [
ListTile(
leading: const Icon(Icons.info_outline),
title: const Text('PluriWave'),
subtitle: const Text('v0.3.0 — Radio mundial'),
),
FutureBuilder<int>(
future: estado.favoritos.obtenerTodos().then((l) => l.length),
builder: (ctx, snap) => ListTile(
leading: const Icon(Icons.favorite_outline),
title: const Text('Favoritos guardados'),
trailing: Text(snap.data?.toString() ?? '',
style: Theme.of(ctx).textTheme.bodyLarge),
),
),
ListTile(
leading: const Icon(Icons.verified_outlined),
title: const Text('Filtro de emisoras'),
subtitle: const Text('Solo emisoras verificadas como activas'),
trailing: const Icon(Icons.check_circle, color: Colors.green),
),
ListTile(
leading: const Icon(Icons.music_note_outlined),
title: const Text('Audio en background'),
subtitle: const Text('Continúa al apagar la pantalla'),
trailing: const Icon(Icons.check_circle, color: Colors.green),
),
],
),
);
}
}

View File

@@ -1,180 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:provider/provider.dart';
import '../estado/estado_radio.dart';
import '../widgets/tarjeta_emisora.dart';
const _paises = [
('España', 'ES'), ('USA', 'US'), ('México', 'MX'), ('Argentina', 'AR'),
('UK', 'GB'), ('Francia', 'FR'), ('Alemania', 'DE'), ('Italia', 'IT'),
('Brasil', 'BR'), ('Japón', 'JP'),
];
const _idiomas = [
'spanish', 'english', 'french', 'german', 'portuguese',
'italian', 'japanese', 'arabic', 'russian',
];
/// Pantalla de búsqueda avanzada de emisoras.
class PantallaBuscar extends StatefulWidget {
const PantallaBuscar({super.key});
@override
State<PantallaBuscar> createState() => _PantallaBuscarState();
}
class _PantallaBuscarState extends State<PantallaBuscar> {
final _controller = TextEditingController();
String? _paisSeleccionado;
String? _idiomaSeleccionado;
bool _buscando = false;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _buscar() {
final q = _controller.text.trim();
context.read<EstadoRadio>().buscar(
nombre: q.isNotEmpty ? q : null,
pais: _paisSeleccionado,
idioma: _idiomaSeleccionado,
);
}
@override
Widget build(BuildContext context) {
final estado = context.watch<EstadoRadio>();
final theme = Theme.of(context);
return Column(
children: [
// Barra de búsqueda
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: SearchBar(
controller: _controller,
hintText: 'Nombre de la emisora...',
leading: const Icon(Icons.search),
trailing: [
if (_controller.text.isNotEmpty)
IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_controller.clear();
setState(() {});
},
),
],
onSubmitted: (_) => _buscar(),
onChanged: (_) => setState(() {}),
),
),
// Filtros país
_seccionFiltro(
theme,
'País',
_paises.map((p) => (p.$1, p.$2)).toList(),
_paisSeleccionado,
(v) => setState(() {
_paisSeleccionado = v;
_buscar();
}),
),
// Filtros idioma
_seccionFiltro(
theme,
'Idioma',
_idiomas.map((i) => (i, i)).toList(),
_idiomaSeleccionado,
(v) => setState(() {
_idiomaSeleccionado = v;
_buscar();
}),
),
// Resultados
Expanded(
child: _resultados(estado, theme),
),
],
);
}
Widget _seccionFiltro(
ThemeData theme,
String titulo,
List<(String, String)> opciones,
String? seleccionado,
void Function(String?) onChanged,
) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(titulo, style: theme.textTheme.labelLarge),
const SizedBox(height: 4),
SizedBox(
height: 36,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: opciones.length,
separatorBuilder: (_, __) => const SizedBox(width: 6),
itemBuilder: (_, i) {
final (label, value) = opciones[i];
final sel = seleccionado == value;
return FilterChip(
label: Text(label),
selected: sel,
visualDensity: VisualDensity.compact,
onSelected: (_) => onChanged(sel ? null : value),
);
},
),
),
],
),
);
}
Widget _resultados(EstadoRadio estado, ThemeData theme) {
if (estado.cargandoBusqueda) {
return const Center(child: CircularProgressIndicator());
}
final resultados = estado.resultadosBusqueda;
if (resultados.isEmpty) {
final sinFiltros = _controller.text.isEmpty &&
_paisSeleccionado == null &&
_idiomaSeleccionado == null;
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.search, size: 64, color: theme.colorScheme.outlineVariant),
const SizedBox(height: 16),
Text(
sinFiltros ? 'Busca una emisora' : 'Sin resultados',
style: theme.textTheme.titleMedium,
),
],
),
);
}
return ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: resultados.length,
separatorBuilder: (_, __) => const SizedBox(height: 4),
itemBuilder: (context, i) => TarjetaEmisora(
emisora: resultados[i],
esCompacta: true,
onTap: () => context.read<EstadoRadio>().reproducir(resultados[i]),
).animate().fadeIn(delay: (i * 20).ms),
);
}
}

View File

@@ -1,75 +0,0 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../estado/estado_radio.dart';
import '../widgets/tarjeta_emisora.dart';
/// Pantalla de emisoras favoritas con reordenado y swipe-to-delete.
class PantallaFavoritos extends StatelessWidget {
const PantallaFavoritos({super.key});
@override
Widget build(BuildContext context) {
final estado = context.watch<EstadoRadio>();
final favoritos = estado.listaFavoritos;
final theme = Theme.of(context);
if (favoritos.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.favorite_border, size: 72, color: theme.colorScheme.outlineVariant),
const SizedBox(height: 16),
Text('Sin favoritos aún', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
Text(
'Toca ♥ en cualquier emisora para guardarla',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
);
}
return ReorderableListView.builder(
padding: const EdgeInsets.all(8),
onReorder: (oldIndex, newIndex) async {
if (newIndex > oldIndex) newIndex--;
final emisora = favoritos[oldIndex];
await estado.favoritos.reordenar(emisora.uuid, newIndex);
await estado.cargarFavoritos();
},
itemCount: favoritos.length,
itemBuilder: (context, i) {
final emisora = favoritos[i];
return Dismissible(
key: Key(emisora.uuid),
direction: DismissDirection.endToStart,
background: Container(
color: theme.colorScheme.error,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 16),
child: Icon(Icons.delete, color: theme.colorScheme.onError),
),
onDismissed: (_) async {
await estado.favoritos.eliminar(emisora.uuid);
await estado.cargarFavoritos();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${emisora.nombre} eliminada de favoritos')),
);
}
},
child: TarjetaEmisora(
key: Key(emisora.uuid),
emisora: emisora,
esCompacta: true,
onTap: () => estado.reproducir(emisora),
),
);
},
);
}
}

View File

@@ -1,214 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:provider/provider.dart';
import 'package:shimmer/shimmer.dart' as shimmer;
import '../estado/estado_radio.dart';
import '../widgets/tarjeta_emisora.dart';
/// Pantalla principal: emisoras populares y por género.
class PantallaInicio extends StatefulWidget {
const PantallaInicio({super.key});
@override
State<PantallaInicio> createState() => _PantallaInicioState();
}
class _PantallaInicioState extends State<PantallaInicio> {
static const _generos = [
'pop', 'rock', 'jazz', 'classical', 'electronic', 'news',
'talk', 'hip-hop', 'country', 'metal', 'reggae', 'latin',
];
String? _generoSeleccionado;
@override
Widget build(BuildContext context) {
final estado = context.watch<EstadoRadio>();
final theme = Theme.of(context);
return RefreshIndicator(
onRefresh: estado.cargarPopulares,
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: _seccionTendencias(estado, theme),
),
SliverToBoxAdapter(
child: _chipGeneros(theme),
),
if (estado.error != null)
SliverToBoxAdapter(
child: _errorBanner(estado, theme),
),
_gridEmisoras(estado, theme),
],
),
);
}
Widget _seccionTendencias(EstadoRadio estado, ThemeData theme) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('🔥 Tendencias', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
SizedBox(
height: 56,
child: estado.cargandoPopulares
? ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: 5,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (_, __) => _ChipShimmer(theme: theme),
)
: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: estado.tendencias.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (context, i) {
final e = estado.tendencias[i];
return ActionChip(
avatar: const Icon(Icons.radio, size: 18),
label: Text(e.nombre, maxLines: 1),
onPressed: () => context.read<EstadoRadio>().reproducir(e),
).animate().fadeIn(delay: (i * 50).ms);
},
),
),
],
),
);
}
Widget _chipGeneros(ThemeData theme) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Géneros', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 4,
children: _generos.map((g) {
final seleccionado = _generoSeleccionado == g;
return FilterChip(
label: Text(g),
selected: seleccionado,
onSelected: (_) {
setState(() {
_generoSeleccionado = seleccionado ? null : g;
});
if (!seleccionado) {
context.read<EstadoRadio>().buscar(tag: g);
} else {
context.read<EstadoRadio>().cargarPopulares();
}
},
);
}).toList(),
),
],
),
);
}
Widget _errorBanner(EstadoRadio estado, ThemeData theme) {
return Padding(
padding: const EdgeInsets.all(16),
child: Card(
color: theme.colorScheme.errorContainer,
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Icon(Icons.wifi_off, color: theme.colorScheme.onErrorContainer),
const SizedBox(width: 8),
Expanded(
child: Text(
estado.error!,
style: TextStyle(color: theme.colorScheme.onErrorContainer),
),
),
TextButton(
onPressed: estado.cargarPopulares,
child: const Text('Reintentar'),
),
],
),
),
),
);
}
Widget _gridEmisoras(EstadoRadio estado, ThemeData theme) {
final emisoras = _generoSeleccionado != null
? estado.resultadosBusqueda
: estado.populares;
final cargando = estado.cargandoPopulares ||
(_generoSeleccionado != null && estado.cargandoBusqueda);
if (cargando) {
return SliverGrid(
delegate: SliverChildBuilderDelegate(
(_, __) => const TarjetaEmisoraShimmer(),
childCount: 12,
),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 0.85,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
);
}
if (emisoras.isEmpty) {
return const SliverFillRemaining(
child: Center(child: Text('No hay emisoras disponibles')),
);
}
return SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverGrid(
delegate: SliverChildBuilderDelegate(
(context, i) => TarjetaEmisora(
emisora: emisoras[i],
onTap: () => context.read<EstadoRadio>().reproducir(emisoras[i]),
).animate().fadeIn(delay: (i * 30).ms).slideY(begin: 0.1),
childCount: emisoras.length,
),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 0.85,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
),
);
}
}
class _ChipShimmer extends StatelessWidget {
final ThemeData theme;
const _ChipShimmer({required this.theme});
@override
Widget build(BuildContext context) {
return shimmer.Shimmer.fromColors(
baseColor: theme.colorScheme.surfaceContainerHighest,
highlightColor: theme.colorScheme.surface,
child: Container(
width: 120,
height: 56,
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
),
);
}
}

View File

@@ -1,481 +0,0 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:provider/provider.dart';
import 'package:shimmer/shimmer.dart';
import '../widgets/visualizador_audio.dart';
import '../estado/estado_radio.dart';
import '../modelos/emisora.dart';
import '../servicios/servicio_audio.dart';
import '../servicios/servicio_timer.dart';
/// Pantalla completa del reproductor de radio.
///
/// Muestra: carátula/logo grande, nombre emisora, información (país, idioma,
/// codec/bitrate), controles play/pause, botón favorito, acceso al timer.
///
/// Se abre como ruta desde cualquier pantalla al pulsar sobre una emisora
/// o desde el MiniReproductor.
class PantallaReproductor extends StatefulWidget {
final Emisora emisora;
const PantallaReproductor({super.key, required this.emisora});
/// Navega a la pantalla del reproductor.
static Future<void> abrir(BuildContext context, Emisora emisora) {
return Navigator.push(
context,
PageRouteBuilder(
pageBuilder: (_, animation, __) => PantallaReproductor(emisora: emisora),
transitionsBuilder: (_, animation, __, child) => SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 1),
end: Offset.zero,
).animate(CurvedAnimation(parent: animation, curve: Curves.easeOutCubic)),
child: child,
),
transitionDuration: const Duration(milliseconds: 350),
),
);
}
@override
State<PantallaReproductor> createState() => _PantallaReproductorState();
}
class _PantallaReproductorState extends State<PantallaReproductor>
with SingleTickerProviderStateMixin {
late AnimationController _pulseController;
bool _esFavorito = false;
@override
void initState() {
super.initState();
_pulseController = AnimationController(
vsync: this,
duration: const Duration(seconds: 2),
);
_checkFavorito();
_iniciarReproduccion();
}
Future<void> _checkFavorito() async {
final estado = context.read<EstadoRadio>();
final fav = await estado.esFavorito(widget.emisora.uuid);
if (mounted) setState(() => _esFavorito = fav);
}
Future<void> _iniciarReproduccion() async {
final estado = context.read<EstadoRadio>();
// Solo reproductor si no es ya la emisora activa
if (estado.emisoraActual?.uuid != widget.emisora.uuid) {
await estado.reproducir(widget.emisora);
}
}
@override
void dispose() {
_pulseController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final estado = context.watch<EstadoRadio>();
return Scaffold(
backgroundColor: theme.colorScheme.surface,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.keyboard_arrow_down_rounded, size: 32),
tooltip: 'Cerrar',
onPressed: () => Navigator.pop(context),
),
actions: [
// Botón favorito
IconButton(
icon: Icon(
_esFavorito ? Icons.favorite_rounded : Icons.favorite_outline_rounded,
color: _esFavorito ? theme.colorScheme.error : null,
),
tooltip: _esFavorito ? 'Quitar de favoritos' : 'Añadir a favoritos',
onPressed: () async {
final esFav = await estado.toggleFavorito(widget.emisora);
if (mounted) setState(() => _esFavorito = esFav);
},
),
],
),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
children: [
const Spacer(flex: 1),
// Carátula / logo grande
_Artwork(
emisora: widget.emisora,
estadoStream: estado.estadoStream,
).animate().scale(begin: const Offset(0.8, 0.8), duration: 400.ms,
curve: Curves.easeOutBack),
const SizedBox(height: 32),
// Nombre de la emisora
Text(
widget.emisora.nombre,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
).animate().fadeIn(delay: 150.ms),
const SizedBox(height: 8),
// Info: país, idioma
_InfoChips(emisora: widget.emisora)
.animate()
.fadeIn(delay: 200.ms)
.slideY(begin: 0.2),
const SizedBox(height: 4),
// Codec / bitrate
if (widget.emisora.codec != null || widget.emisora.bitrate != null)
Text(
_codecInfo(widget.emisora),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
).animate().fadeIn(delay: 250.ms),
const SizedBox(height: 16),
// Visualizador de audio
VisualizadorAudio(
estadoStream: estado.estadoStream,
barras: 24,
color: theme.colorScheme.primary,
altura: 48,
).animate().fadeIn(delay: 280.ms),
const Spacer(flex: 2),
// Controles
_Controles(
estado: estado,
emisora: widget.emisora,
).animate().fadeIn(delay: 300.ms).slideY(begin: 0.3),
const SizedBox(height: 24),
// Timer
_TimerWidget(estado: estado)
.animate()
.fadeIn(delay: 400.ms),
const Spacer(flex: 1),
],
),
),
),
);
}
String _codecInfo(Emisora e) {
final parts = <String>[];
if (e.codec != null) parts.add(e.codec!.toUpperCase());
if (e.bitrate != null && e.bitrate! > 0) parts.add('${e.bitrate} kbps');
return parts.join(' · ');
}
}
// ─── Artwork ────────────────────────────────────────────────────────────────
class _Artwork extends StatelessWidget {
final Emisora emisora;
final Stream<EstadoReproduccion> estadoStream;
const _Artwork({required this.emisora, required this.estadoStream});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final size = MediaQuery.of(context).size.width * 0.65;
return StreamBuilder<EstadoReproduccion>(
stream: estadoStream,
builder: (context, snapshot) {
final reproduciendo = snapshot.data == EstadoReproduccion.reproduciendo;
final cargando = snapshot.data == EstadoReproduccion.cargando;
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
width: size,
height: size,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
boxShadow: reproduciendo
? [
BoxShadow(
color: theme.colorScheme.primary.withValues(alpha: 0.4),
blurRadius: 30,
spreadRadius: 5,
),
]
: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.2),
blurRadius: 12,
spreadRadius: 2,
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: Stack(
fit: StackFit.expand,
children: [
// Logo / imagen
if (emisora.favicon != null && emisora.favicon!.isNotEmpty)
CachedNetworkImage(
imageUrl: emisora.favicon!,
fit: BoxFit.cover,
placeholder: (_, __) => _shimmer(theme),
errorWidget: (_, __, ___) => _iconoFallback(theme),
)
else
_iconoFallback(theme),
// Overlay de carga
if (cargando)
Container(
color: Colors.black45,
child: const Center(
child: CircularProgressIndicator(color: Colors.white),
),
),
],
),
),
);
},
);
}
Widget _shimmer(ThemeData theme) => Shimmer.fromColors(
baseColor: theme.colorScheme.surfaceContainerHighest,
highlightColor: theme.colorScheme.surface,
child: Container(color: theme.colorScheme.surfaceContainerHighest),
);
Widget _iconoFallback(ThemeData theme) => Container(
color: theme.colorScheme.primaryContainer,
child: Icon(
Icons.radio_rounded,
size: 80,
color: theme.colorScheme.onPrimaryContainer,
),
);
}
// ─── Info chips ─────────────────────────────────────────────────────────────
class _InfoChips extends StatelessWidget {
final Emisora emisora;
const _InfoChips({required this.emisora});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final items = <String>[];
if (emisora.pais != null) items.add(emisora.pais!);
if (emisora.idioma != null) items.add(emisora.idioma!);
if (items.isEmpty) return const SizedBox.shrink();
return Wrap(
spacing: 6,
children: items
.map((label) => Chip(
label: Text(label),
visualDensity: VisualDensity.compact,
backgroundColor: theme.colorScheme.secondaryContainer,
labelStyle: TextStyle(
color: theme.colorScheme.onSecondaryContainer,
fontSize: 12),
padding: EdgeInsets.zero,
))
.toList(),
);
}
}
// ─── Controles ──────────────────────────────────────────────────────────────
class _Controles extends StatelessWidget {
final EstadoRadio estado;
final Emisora emisora;
const _Controles({required this.estado, required this.emisora});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return StreamBuilder<EstadoReproduccion>(
stream: estado.estadoStream,
builder: (context, snapshot) {
final s = snapshot.data ?? EstadoReproduccion.detenido;
final reproduciendo = s == EstadoReproduccion.reproduciendo;
final cargando = s == EstadoReproduccion.cargando;
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Botón detener
IconButton(
icon: const Icon(Icons.stop_rounded),
iconSize: 36,
color: theme.colorScheme.onSurfaceVariant,
tooltip: 'Detener',
onPressed: cargando ? null : () => estado.audio.detener(),
),
const SizedBox(width: 16),
// Botón play/pause principal
AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 72,
height: 72,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: theme.colorScheme.primary,
boxShadow: [
BoxShadow(
color: theme.colorScheme.primary.withValues(alpha: 0.35),
blurRadius: reproduciendo ? 16 : 6,
spreadRadius: reproduciendo ? 4 : 0,
),
],
),
child: Material(
color: Colors.transparent,
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: cargando
? null
: () {
if (reproduciendo || s == EstadoReproduccion.pausado) {
estado.togglePlay();
} else {
estado.reproducir(emisora);
}
},
child: Center(
child: cargando
? const SizedBox(
width: 28,
height: 28,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: Colors.white,
),
)
: Icon(
reproduciendo
? Icons.pause_rounded
: Icons.play_arrow_rounded,
size: 40,
color: theme.colorScheme.onPrimary,
),
),
),
),
),
const SizedBox(width: 16),
// Indicador en vivo
Icon(
Icons.fiber_manual_record_rounded,
size: 36,
color: reproduciendo
? theme.colorScheme.error
: theme.colorScheme.surfaceContainerHighest,
),
],
);
},
);
}
}
// ─── Timer widget ────────────────────────────────────────────────────────────
class _TimerWidget extends StatelessWidget {
final EstadoRadio estado;
const _TimerWidget({required this.estado});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (!estado.timer.activo) {
return TextButton.icon(
icon: const Icon(Icons.bedtime_outlined, size: 18),
label: const Text('Timer de sueño'),
onPressed: () => _mostrarTimerDialog(context),
);
}
return StreamBuilder<Duration>(
stream: estado.timer.tiempoRestanteStream,
builder: (context, snap) {
final t = snap.data ?? Duration.zero;
final m = t.inMinutes.remainder(60).toString().padLeft(2, '0');
final s = t.inSeconds.remainder(60).toString().padLeft(2, '0');
final label = t.inHours > 0
? '${t.inHours}h ${m}m'
: '${m}m ${s}s';
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.bedtime_rounded, size: 16, color: theme.colorScheme.primary),
const SizedBox(width: 6),
Text(label, style: theme.textTheme.bodyMedium),
const SizedBox(width: 8),
TextButton(
onPressed: () => estado.cancelarTimer(),
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
),
child: const Text('Cancelar'),
),
],
);
},
);
}
void _mostrarTimerDialog(BuildContext context) {
showModalBottomSheet(
context: context,
builder: (ctx) => SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Timer de sueño', style: Theme.of(ctx).textTheme.titleLarge),
const SizedBox(height: 16),
Wrap(
spacing: 8,
children: opcionesTimer
.map((min) => ActionChip(
label: Text('$min min'),
onPressed: () {
estado.iniciarTimer(min);
Navigator.pop(ctx);
},
))
.toList(),
),
],
),
),
),
);
}
}

View File

@@ -1,228 +0,0 @@
import 'package:audio_service/audio_service.dart';
import 'package:just_audio/just_audio.dart';
import '../modelos/emisora.dart';
import '../modelos/preset_ecualizador.dart';
/// Estado de reproducción expuesto al UI.
enum EstadoReproduccion { detenido, cargando, reproduciendo, pausado, error }
// ─────────────────────────────────────────────────────────────
// Handler global — inicializado en main.dart con AudioService.init
// ─────────────────────────────────────────────────────────────
PluriWaveAudioHandler? _handlerGlobal;
void registrarHandler(PluriWaveAudioHandler handler) {
_handlerGlobal = handler;
}
/// Wrapper de alto nivel para el UI.
class ServicioAudio {
PluriWaveAudioHandler get _handler {
assert(_handlerGlobal != null, 'registrarHandler() no fue llamado en main.dart');
return _handlerGlobal!;
}
Emisora? get emisoraActual => _handler.emisoraActual;
Stream<EstadoReproduccion> get estadoStream =>
_handler.playbackState.map((s) {
if (s.processingState == AudioProcessingState.loading ||
s.processingState == AudioProcessingState.buffering) {
return EstadoReproduccion.cargando;
}
if (s.playing) return EstadoReproduccion.reproduciendo;
if (s.processingState == AudioProcessingState.idle) {
return EstadoReproduccion.detenido;
}
return EstadoReproduccion.pausado;
});
Future<void> reproducir(Emisora emisora) async {
final item = MediaItem(
id: emisora.url,
title: emisora.nombre,
artist: emisora.pais ?? '',
album: 'PluriWave',
artUri: emisora.favicon != null && emisora.favicon!.isNotEmpty
? Uri.tryParse(emisora.favicon!)
: null,
extras: {'uuid': emisora.uuid},
);
await _handler.playMediaItem(item);
}
Future<void> pausar() => _handler.pause();
Future<void> reanudar() => _handler.play();
Future<void> togglePlay() async {
if (_handler.playbackState.value.playing) {
await pausar();
} else {
await reanudar();
}
}
Future<void> detener() => _handler.stop();
Future<void> setVolumen(double vol) => _handler.setVolumen(vol);
double get volumen => _handler.volumen;
bool get estaSonando => _handler.playbackState.value.playing;
Future<void> dispose() async {}
// ── Ecualizador ──────────────────────────────────────────────────────────
AndroidEqualizer? get ecualizador => _handler.ecualizador;
bool get ecualizadorDisponible => _handler.ecualizadorDisponible;
PresetEcualizador get presetActual => _handler.presetActual;
Future<void> aplicarPreset(PresetEcualizador preset) =>
_handler.aplicarPreset(preset);
Future<void> setBanda(int index, double db) =>
_handler.setBanda(index, db);
}
// ─────────────────────────────────────────────────────────────
// AudioHandler
// ─────────────────────────────────────────────────────────────
class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
final AndroidEqualizer _eq = AndroidEqualizer();
late final AudioPlayer _player = AudioPlayer(
audioPipeline: AudioPipeline(androidAudioEffects: [_eq]),
);
Emisora? emisoraActual;
double _volumen = 1.0;
double get volumen => _volumen;
AndroidEqualizer? get ecualizador => _eq;
bool _eqDisponible = false;
bool get ecualizadorDisponible => _eqDisponible;
PresetEcualizador _presetActual = PresetEcualizador.flat;
PresetEcualizador get presetActual => _presetActual;
PluriWaveAudioHandler() {
_setupStreams();
}
void _setupStreams() {
_player.playerStateStream.listen((state) {
final playing = state.playing;
final proc = state.processingState;
playbackState.add(playbackState.value.copyWith(
controls: [
if (playing) MediaControl.pause else MediaControl.play,
MediaControl.stop,
],
systemActions: const {MediaAction.seek, MediaAction.stop},
androidCompactActionIndices: const [0],
processingState: _mapProcState(proc),
playing: playing,
bufferedPosition: _player.bufferedPosition,
speed: _player.speed,
));
});
_player.bufferedPositionStream.listen((pos) {
playbackState.add(playbackState.value.copyWith(bufferedPosition: pos));
});
}
AudioProcessingState _mapProcState(ProcessingState state) {
return switch (state) {
ProcessingState.idle => AudioProcessingState.idle,
ProcessingState.loading => AudioProcessingState.loading,
ProcessingState.buffering => AudioProcessingState.buffering,
ProcessingState.ready => AudioProcessingState.ready,
ProcessingState.completed => AudioProcessingState.completed,
};
}
@override
Future<void> playMediaItem(MediaItem item) async {
mediaItem.add(item);
try {
await _player.stop();
await _player.setUrl(item.id);
await _player.play();
// Habilitar ecualizador tras reproducir (necesita audio activo)
await _activarEcualizador();
} on PlayerException catch (e) {
playbackState.add(playbackState.value.copyWith(
processingState: AudioProcessingState.error,
errorMessage: e.message ?? 'Error de reproducción',
errorCode: e.code,
));
rethrow;
}
}
Future<void> _activarEcualizador() async {
try {
final params = await _eq.parameters;
_eqDisponible = params.bands.isNotEmpty;
if (_eqDisponible) {
await _eq.setEnabled(true);
await aplicarPreset(_presetActual);
}
} catch (_) {
_eqDisponible = false;
}
}
/// Aplica un preset al ecualizador nativo Android.
Future<void> aplicarPreset(PresetEcualizador preset) async {
_presetActual = preset;
if (!_eqDisponible) return;
try {
final params = await _eq.parameters;
for (int i = 0; i < params.bands.length && i < preset.bandas.length; i++) {
await params.bands[i].setGain(preset.bandas[i]);
}
} catch (_) {}
}
/// Ajusta una banda individual.
Future<void> setBanda(int index, double db) async {
if (!_eqDisponible) return;
final bandas = List<double>.from(_presetActual.bandas);
if (index >= 0 && index < bandas.length) {
bandas[index] = db;
_presetActual = _presetActual.copyWithBandas(bandas);
}
try {
final params = await _eq.parameters;
if (index < params.bands.length) {
await params.bands[index].setGain(db);
}
} catch (_) {}
}
Future<void> setVolumen(double vol) async {
_volumen = vol.clamp(0.0, 1.0);
await _player.setVolume(_volumen);
}
@override
Future<void> play() => _player.play();
@override
Future<void> pause() => _player.pause();
@override
Future<void> stop() async {
await _player.stop();
emisoraActual = null;
mediaItem.add(null);
await super.stop();
}
@override
Future<void> seek(Duration position) => _player.seek(position);
@override
Future<void> onTaskRemoved() async {
await stop();
await _player.dispose();
}
}

View File

@@ -1,125 +0,0 @@
import 'package:path/path.dart';
import 'package:sqflite/sqflite.dart';
import '../modelos/emisora.dart';
/// Servicio de persistencia de emisoras favoritas con SQLite.
///
/// - Inicialización lazy: la BD se abre en el primer acceso.
/// - Migration-ready: versión 2 añade campos de la Radio Browser API.
class ServicioFavoritos {
static const _dbName = 'pluriwave.db';
static const _dbVersion = 2;
Database? _db;
Future<Database> get _database async {
_db ??= await _initDb();
return _db!;
}
Future<Database> _initDb() async {
final dbPath = await getDatabasesPath();
final path = join(dbPath, _dbName);
return openDatabase(
path,
version: _dbVersion,
onCreate: _onCreate,
onUpgrade: _onUpgrade,
);
}
Future<void> _onCreate(Database db, int version) async {
await db.execute('''
CREATE TABLE favoritos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT NOT NULL UNIQUE,
nombre TEXT NOT NULL,
url TEXT NOT NULL,
favicon TEXT,
pais TEXT,
codigo_pais TEXT,
idioma TEXT,
tags TEXT,
codec TEXT,
bitrate INTEGER,
votes INTEGER NOT NULL DEFAULT 0,
clickcount INTEGER NOT NULL DEFAULT 0,
orden INTEGER NOT NULL DEFAULT 0
)
''');
}
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
if (oldVersion < 2) {
// v1→v2: añadir columnas de la Radio Browser API
await db.execute('ALTER TABLE favoritos ADD COLUMN codigo_pais TEXT');
await db.execute('ALTER TABLE favoritos ADD COLUMN codec TEXT');
await db.execute('ALTER TABLE favoritos ADD COLUMN bitrate INTEGER');
await db.execute('ALTER TABLE favoritos ADD COLUMN votes INTEGER NOT NULL DEFAULT 0');
await db.execute('ALTER TABLE favoritos ADD COLUMN clickcount INTEGER NOT NULL DEFAULT 0');
}
}
/// Devuelve todas las emisoras favoritas ordenadas por [orden].
Future<List<Emisora>> obtenerTodos() async {
final db = await _database;
final rows = await db.query('favoritos', orderBy: 'orden ASC');
return rows.map(Emisora.fromMap).toList();
}
/// Añade una emisora a favoritos. Si ya existe (mismo uuid), la actualiza.
Future<void> agregar(Emisora emisora) async {
final db = await _database;
// Calcular el siguiente orden
final maxOrden = Sqflite.firstIntValue(
await db.rawQuery('SELECT MAX(orden) FROM favoritos'),
) ??
-1;
final nuevaEmisora = emisora.copyWith(orden: maxOrden + 1);
await db.insert(
'favoritos',
nuevaEmisora.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
/// Elimina una emisora de favoritos por [uuid].
Future<void> eliminar(String uuid) async {
final db = await _database;
await db.delete('favoritos', where: 'uuid = ?', whereArgs: [uuid]);
}
/// Devuelve true si la emisora con [uuid] está en favoritos.
Future<bool> esFavorito(String uuid) async {
final db = await _database;
final count = Sqflite.firstIntValue(
await db.rawQuery(
'SELECT COUNT(*) FROM favoritos WHERE uuid = ?',
[uuid],
),
);
return (count ?? 0) > 0;
}
/// Alterna el estado de favorito de una emisora.
Future<bool> toggleFavorito(Emisora emisora) async {
if (await esFavorito(emisora.uuid)) {
await eliminar(emisora.uuid);
return false;
} else {
await agregar(emisora);
return true;
}
}
/// Actualiza el orden de un favorito.
Future<void> reordenar(String uuid, int nuevoOrden) async {
final db = await _database;
await db.update(
'favoritos',
{'orden': nuevoOrden},
where: 'uuid = ?',
whereArgs: [uuid],
);
}
}

View File

@@ -1,148 +0,0 @@
import 'dart:convert';
import 'dart:math';
import 'package:http/http.dart' as http;
import '../modelos/emisora.dart';
/// Cliente para la Radio Browser API (https://api.radio-browser.info/).
///
/// Selecciona automáticamente un servidor disponible de entre los DNS
/// resueltos para `all.api.radio-browser.info` y rota en caso de error.
///
/// ### Rate limiting
/// La API no tiene límite documentado, pero por cortesía limitamos a
/// peticiones con `?limit` explícito y no hacemos polling automático.
class ServicioRadio {
static const _dnsHost = 'all.api.radio-browser.info';
static const _timeout = Duration(seconds: 10);
// Servidores conocidos como fallback si el DNS falla
static const _servidoresFallback = [
'de1.api.radio-browser.info',
'nl1.api.radio-browser.info',
'at1.api.radio-browser.info',
];
String? _servidorActual;
Future<String> _servidor() async {
if (_servidorActual != null) return _servidorActual!;
// Intentar DNS lookup simplificado — usamos fallback directamente
final servidores = List<String>.from(_servidoresFallback)..shuffle(Random());
_servidorActual = servidores.first;
return _servidorActual!;
}
Uri _uri(String servidor, String path, Map<String, String> params) {
return Uri.https(servidor, path, {
'hidebroken': 'true',
...params,
});
}
Future<List<Emisora>> _get(String path, Map<String, String> params) async {
final servidor = await _servidor();
// lastcheckok=1 filtra emisoras que la API verificó como funcionales
final uri = _uri(servidor, path, {
'lastcheckok': '1',
...params,
});
try {
final resp = await http.get(uri, headers: {
'User-Agent': 'PluriWave/0.1.0 (es.freetimelab.pluriwave)',
}).timeout(_timeout);
if (resp.statusCode != 200) {
throw Exception('API error ${resp.statusCode}');
}
final lista = json.decode(resp.body) as List<dynamic>;
return lista
.cast<Map<String, dynamic>>()
.map(Emisora.fromApi)
.where((e) => e.uuid.isNotEmpty && e.url.isNotEmpty)
.toList();
} catch (e) {
// Rotar servidor en el siguiente intento
_servidorActual = null;
rethrow;
}
}
/// Emisoras más votadas globalmente.
Future<List<Emisora>> obtenerPopulares({int limit = 30}) async {
return _get('/json/stations/topvote/$limit', {});
}
/// Emisoras más escuchadas (por clicks) globalmente.
Future<List<Emisora>> obtenerTendencias({int limit = 20}) async {
return _get('/json/stations/topclick/$limit', {});
}
/// Buscar por nombre de emisora.
Future<List<Emisora>> buscarPorNombre(String query, {int limit = 30}) async {
return _get('/json/stations/search', {
'name': query,
'limit': limit.toString(),
'order': 'votes',
'reverse': 'true',
});
}
/// Buscar por código de país (ISO 3166-1 alpha-2, e.g. 'ES', 'US').
Future<List<Emisora>> buscarPorPais(String codigoPais, {int limit = 50}) async {
return _get('/json/stations/bycountrycodeexact/$codigoPais', {
'limit': limit.toString(),
'order': 'votes',
'reverse': 'true',
});
}
/// Buscar por idioma (e.g. 'spanish', 'english').
Future<List<Emisora>> buscarPorIdioma(String idioma, {int limit = 30}) async {
return _get('/json/stations/bylanguageexact/$idioma', {
'limit': limit.toString(),
'order': 'votes',
'reverse': 'true',
});
}
/// Buscar por tag/género (e.g. 'rock', 'jazz', 'pop').
Future<List<Emisora>> buscarPorTag(String tag, {int limit = 30}) async {
return _get('/json/stations/bytagexact/$tag', {
'limit': limit.toString(),
'order': 'votes',
'reverse': 'true',
});
}
/// Búsqueda combinada: permite combinar nombre, país, idioma y tag.
Future<List<Emisora>> buscar({
String? nombre,
String? pais,
String? idioma,
String? tag,
int limit = 30,
}) async {
return _get('/json/stations/search', {
if (nombre != null && nombre.isNotEmpty) 'name': nombre,
if (pais != null && pais.isNotEmpty) 'countrycode': pais,
if (idioma != null && idioma.isNotEmpty) 'language': idioma,
if (tag != null && tag.isNotEmpty) 'tag': tag,
'limit': limit.toString(),
'order': 'votes',
'reverse': 'true',
});
}
/// Registrar un click en la API (buenas prácticas de ciudadanía API).
Future<void> registrarClick(String uuid) async {
try {
final servidor = await _servidor();
await http.get(
Uri.https(servidor, '/json/url/$uuid'),
headers: {'User-Agent': 'PluriWave/0.1.0 (es.freetimelab.pluriwave)'},
).timeout(_timeout);
} catch (_) {
// No crítico — ignorar silenciosamente
}
}
}

View File

@@ -1,97 +0,0 @@
import 'dart:async';
import 'servicio_audio.dart';
/// Opciones predefinidas de timer en minutos.
const opcionesTimer = [15, 30, 60, 90];
/// Servicio de auto-apagado de la radio.
///
/// Cuenta atrás desde la duración elegida. Cuando llega a 0:
/// 1. Hace un fade out de [_fadeDuracion] segundos.
/// 2. Llama a [ServicioAudio.detener].
///
/// El UI puede escuchar [tiempoRestanteStream] para mostrar el contador.
class ServicioTimer {
final ServicioAudio _audio;
Timer? _timer;
Timer? _fadeTicker;
DateTime? _finAt;
Duration _tiempoRestante = Duration.zero;
bool _activo = false;
static const _fadeDuracion = Duration(seconds: 30);
static const _fadeStep = Duration(seconds: 1);
final _controller = StreamController<Duration>.broadcast();
ServicioTimer(this._audio);
/// Stream que emite el tiempo restante cada segundo.
Stream<Duration> get tiempoRestanteStream => _controller.stream;
Duration get tiempoRestante => _tiempoRestante;
bool get activo => _activo;
/// Inicia el timer para [minutos] minutos.
void iniciar(int minutos) {
cancelar();
final duracion = Duration(minutes: minutos);
_finAt = DateTime.now().add(duracion);
_tiempoRestante = duracion;
_activo = true;
_controller.add(_tiempoRestante);
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
final ahora = DateTime.now();
final restante = _finAt!.difference(ahora);
if (restante <= Duration.zero) {
_tiempoRestante = Duration.zero;
_controller.add(_tiempoRestante);
_iniciarFadeOut();
cancelar(detenerAudio: false);
} else {
_tiempoRestante = restante;
_controller.add(_tiempoRestante);
}
});
}
void _iniciarFadeOut() {
final pasos = _fadeDuracion.inSeconds;
final volumenInicial = _audio.volumen;
int paso = 0;
_fadeTicker = Timer.periodic(_fadeStep, (_) async {
paso++;
final nuevoVolumen = volumenInicial * (1 - paso / pasos);
await _audio.setVolumen(nuevoVolumen.clamp(0.0, 1.0));
if (paso >= pasos) {
_fadeTicker?.cancel();
await _audio.detener();
await _audio.setVolumen(volumenInicial); // restaurar volumen para próxima vez
}
});
}
/// Cancela el timer activo sin detener el audio.
void cancelar({bool detenerAudio = false}) {
_timer?.cancel();
_timer = null;
_fadeTicker?.cancel();
_fadeTicker = null;
_activo = false;
_tiempoRestante = Duration.zero;
_controller.add(_tiempoRestante);
if (detenerAudio) {
_audio.detener();
}
}
void dispose() {
cancelar();
_controller.close();
}
}

View File

@@ -1,120 +0,0 @@
import 'package:flutter/material.dart';
import '../modelos/preset_ecualizador.dart';
/// Widget de ecualizador con 5 sliders verticales.
/// Basado en JaviHogar EcualizadorWidget, adaptado a Material You.
class EcualizadorWidget extends StatefulWidget {
final PresetEcualizador preset;
final void Function(PresetEcualizador) onCambio;
const EcualizadorWidget({super.key, required this.preset, required this.onCambio});
@override
State<EcualizadorWidget> createState() => _EcualizadorWidgetState();
}
class _EcualizadorWidgetState extends State<EcualizadorWidget> {
late List<double> _bandas;
final List<String> _etiquetas = ['60Hz', '250Hz', '1kHz', '4kHz', '16kHz'];
@override
void initState() {
super.initState();
_bandas = List.from(widget.preset.bandas);
}
@override
void didUpdateWidget(EcualizadorWidget old) {
super.didUpdateWidget(old);
if (old.preset.nombre != widget.preset.nombre) {
setState(() => _bandas = List.from(widget.preset.bandas));
}
}
void _actualizarBanda(int index, double valor) {
setState(() => _bandas[index] = valor);
widget.onCambio(PresetEcualizador(nombre: 'Personalizado', bandas: List.from(_bandas)));
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Ecualizador', style: theme.textTheme.titleMedium),
const SizedBox(height: 16),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
for (int i = 0; i < 5; i++)
Expanded(
child: Column(
children: [
SizedBox(
height: 160,
child: RotatedBox(
quarterTurns: 3,
child: Slider(
value: _bandas[i],
min: -12.0,
max: 12.0,
divisions: 24,
onChanged: (v) => _actualizarBanda(i, v),
),
),
),
Text(
'${_bandas[i].toStringAsFixed(1)}dB',
style: theme.textTheme.labelSmall,
textAlign: TextAlign.center,
),
Text(
_etiquetas[i],
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
],
),
),
],
),
],
),
),
);
}
}
/// Chips de presets predefinidos.
class PresetsEcualizadorWidget extends StatelessWidget {
final PresetEcualizador presetActual;
final void Function(PresetEcualizador) onSeleccionar;
const PresetsEcualizadorWidget({
super.key,
required this.presetActual,
required this.onSeleccionar,
});
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 8,
runSpacing: 4,
children: PresetEcualizador.presets.map((p) {
return ChoiceChip(
label: Text(p.nombre),
selected: p.nombre == presetActual.nombre,
onSelected: (_) => onSeleccionar(p),
);
}).toList(),
);
}
}

View File

@@ -1,125 +0,0 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../estado/estado_radio.dart';
import '../pantallas/pantalla_reproductor.dart';
import '../servicios/servicio_audio.dart';
import 'visualizador_audio.dart';
/// Barra inferior persistente con controles básicos de reproducción.
/// Toca la barra para abrir PantallaReproductor completa.
class MiniReproductor extends StatelessWidget {
const MiniReproductor({super.key});
@override
Widget build(BuildContext context) {
final estado = context.watch<EstadoRadio>();
final emisora = estado.emisoraActual;
if (emisora == null) return const SizedBox.shrink();
final theme = Theme.of(context);
return GestureDetector(
onTap: () => PantallaReproductor.abrir(context, emisora),
child: Container(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainer,
border: Border(
top: BorderSide(
color: theme.colorScheme.outlineVariant, width: 0.5)),
),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
// Indicador de reproducción (mini visualizador)
SizedBox(
width: 40,
height: 40,
child: Center(
child: IndicadorReproduccion(
estadoStream: estado.estadoStream,
color: theme.colorScheme.primary,
size: 20,
),
),
),
const SizedBox(width: 12),
// Nombre y estado
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
emisora.nombre,
style: theme.textTheme.bodyMedium
?.copyWith(fontWeight: FontWeight.w600),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
StreamBuilder<EstadoReproduccion>(
stream: estado.estadoStream,
builder: (context, snapshot) {
final s = snapshot.data ??
EstadoReproduccion.detenido;
return Text(
_labelEstado(s),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
);
},
),
],
),
),
// Botón play/pause
StreamBuilder<EstadoReproduccion>(
stream: estado.estadoStream,
builder: (context, snapshot) {
final s =
snapshot.data ?? EstadoReproduccion.detenido;
if (s == EstadoReproduccion.cargando) {
return const SizedBox(
width: 40,
height: 40,
child: Padding(
padding: EdgeInsets.all(10),
child: CircularProgressIndicator(strokeWidth: 2),
),
);
}
return IconButton(
icon: Icon(
s == EstadoReproduccion.reproduciendo
? Icons.pause_rounded
: Icons.play_arrow_rounded,
),
onPressed: () {
// Evitar que el tap en el botón abra el reproductor
estado.togglePlay();
},
);
},
),
],
),
),
),
),
);
}
String _labelEstado(EstadoReproduccion estado) {
return switch (estado) {
EstadoReproduccion.cargando => 'Conectando...',
EstadoReproduccion.reproduciendo => 'En directo ●',
EstadoReproduccion.pausado => 'Pausado',
EstadoReproduccion.error => 'Error de conexión',
EstadoReproduccion.detenido => 'Detenido',
};
}
}

View File

@@ -1,210 +0,0 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:shimmer/shimmer.dart';
import '../estado/estado_radio.dart';
import '../modelos/emisora.dart';
/// Tarjeta compacta para mostrar una emisora en listas y grids.
/// Incluye botón de favorito visible en ambos modos.
class TarjetaEmisora extends StatefulWidget {
final Emisora emisora;
final VoidCallback? onTap;
final bool esCompacta;
const TarjetaEmisora({
super.key,
required this.emisora,
this.onTap,
this.esCompacta = false,
});
@override
State<TarjetaEmisora> createState() => _TarjetaEmisoraState();
}
class _TarjetaEmisoraState extends State<TarjetaEmisora> {
bool _esFavorito = false;
bool _toggling = false;
@override
void initState() {
super.initState();
_checkFavorito();
}
Future<void> _checkFavorito() async {
final fav = await context.read<EstadoRadio>().esFavorito(widget.emisora.uuid);
if (mounted) setState(() => _esFavorito = fav);
}
Future<void> _toggle() async {
if (_toggling) return;
_toggling = true;
final estado = context.read<EstadoRadio>();
final esFav = await estado.toggleFavorito(widget.emisora);
if (mounted) setState(() => _esFavorito = esFav);
_toggling = false;
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(esFav
? '${widget.emisora.nombre} añadida a favoritos'
: '${widget.emisora.nombre} eliminada de favoritos'),
duration: const Duration(seconds: 2),
));
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: widget.onTap,
child: widget.esCompacta
? _buildCompacta(theme)
: _buildCompleta(theme),
),
);
}
Widget _buildCompleta(ThemeData theme) {
return Stack(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 1,
child: _logo(theme, 60),
),
Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.emisora.nombre,
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (widget.emisora.pais != null)
Text(
widget.emisora.pais!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
// Botón favorito superpuesto (esquina superior derecha)
Positioned(
top: 4,
right: 4,
child: _botonFavorito(theme, mini: true),
),
],
);
}
Widget _buildCompacta(ThemeData theme) {
return ListTile(
leading: SizedBox(width: 48, height: 48, child: _logo(theme, 24)),
title: Text(
widget.emisora.nombre,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
[widget.emisora.pais, widget.emisora.idioma]
.where((s) => s != null && s.isNotEmpty)
.join(' · '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: _botonFavorito(theme, mini: false),
);
}
Widget _botonFavorito(ThemeData theme, {required bool mini}) {
return Material(
color: mini
? theme.colorScheme.surface.withValues(alpha: 0.8)
: Colors.transparent,
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: _toggle,
child: Padding(
padding: EdgeInsets.all(mini ? 6 : 4),
child: Icon(
_esFavorito ? Icons.favorite_rounded : Icons.favorite_outline_rounded,
color: _esFavorito ? theme.colorScheme.error : theme.colorScheme.onSurfaceVariant,
size: mini ? 18 : 22,
),
),
),
);
}
Widget _logo(ThemeData theme, double iconSize) {
if (widget.emisora.favicon != null && widget.emisora.favicon!.isNotEmpty) {
return CachedNetworkImage(
imageUrl: widget.emisora.favicon!,
fit: BoxFit.cover,
placeholder: (_, __) => _shimmer(theme),
errorWidget: (_, __, ___) => _iconoFallback(theme, iconSize),
);
}
return _iconoFallback(theme, iconSize);
}
Widget _shimmer(ThemeData theme) {
return Shimmer.fromColors(
baseColor: theme.colorScheme.surfaceContainerHighest,
highlightColor: theme.colorScheme.surface,
child: Container(color: theme.colorScheme.surfaceContainerHighest),
);
}
Widget _iconoFallback(ThemeData theme, double size) {
return Container(
color: theme.colorScheme.primaryContainer,
child: Icon(Icons.radio, size: size, color: theme.colorScheme.onPrimaryContainer),
);
}
}
/// Placeholder shimmer para listas en carga.
class TarjetaEmisoraShimmer extends StatelessWidget {
const TarjetaEmisoraShimmer({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Shimmer.fromColors(
baseColor: theme.colorScheme.surfaceContainerHighest,
highlightColor: theme.colorScheme.surface,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 1,
child: Container(color: theme.colorScheme.surfaceContainerHighest),
),
const SizedBox(height: 8),
Container(height: 14, color: theme.colorScheme.surfaceContainerHighest),
const SizedBox(height: 4),
Container(height: 12, width: 60, color: theme.colorScheme.surfaceContainerHighest),
],
),
);
}
}

View File

@@ -1,241 +0,0 @@
import 'dart:math';
import 'package:flutter/material.dart';
import '../servicios/servicio_audio.dart';
/// Visualizador de audio animado para la pantalla del reproductor.
///
/// Muestra barras verticales que se animan con movimiento pseudo-aleatorio
/// basado en ruido suavizado mientras la radio está reproduciéndose.
/// Cuando está pausado/detenido, las barras se aplanan suavemente.
///
/// ### Implementación
/// No usa FFT real (requeriría captura de micrófono con permisos).
/// En cambio, usa un generador de movimiento orgánico con interpolación
/// suavizada — el resultado visual es similar al de apps de streaming como
/// Spotify o Apple Music en sus visualizadores de "en reproducción".
///
/// ### Uso
/// ```dart
/// VisualizadorAudio(
/// estadoStream: estado.estadoStream,
/// barras: 24,
/// color: theme.colorScheme.primary,
/// altura: 60,
/// )
/// ```
class VisualizadorAudio extends StatefulWidget {
final Stream<EstadoReproduccion> estadoStream;
final int barras;
final Color? color;
final double altura;
final double anchuraTotal;
const VisualizadorAudio({
super.key,
required this.estadoStream,
this.barras = 20,
this.color,
this.altura = 48,
this.anchuraTotal = double.infinity,
});
@override
State<VisualizadorAudio> createState() => _VisualizadorAudioState();
}
class _VisualizadorAudioState extends State<VisualizadorAudio>
with TickerProviderStateMixin {
late AnimationController _controller;
late List<_BarraState> _barras;
final _random = Random();
bool _activo = false;
@override
void initState() {
super.initState();
_barras = List.generate(
widget.barras,
(i) => _BarraState(
fase: _random.nextDouble() * pi * 2,
velocidad: 0.8 + _random.nextDouble() * 1.4,
amplitud: 0.4 + _random.nextDouble() * 0.6,
offset: _random.nextDouble() * 0.3,
),
);
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 1),
)..addListener(_actualizar);
widget.estadoStream.listen(_onEstado);
}
void _onEstado(EstadoReproduccion estado) {
final nuevoActivo = estado == EstadoReproduccion.reproduciendo ||
estado == EstadoReproduccion.cargando;
if (nuevoActivo == _activo) return;
setState(() => _activo = nuevoActivo);
if (nuevoActivo) {
_controller.repeat();
} else {
_controller.forward(from: _controller.value).whenComplete(() {
if (!_activo && mounted) _controller.stop();
});
}
}
void _actualizar() {
if (mounted) setState(() {});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final color = widget.color ?? Theme.of(context).colorScheme.primary;
final t = _controller.value * pi * 2;
return SizedBox(
height: widget.altura,
child: LayoutBuilder(
builder: (context, constraints) {
final totalAncho = constraints.maxWidth == double.infinity
? 300.0
: constraints.maxWidth;
final espaciado = totalAncho / widget.barras;
final anchoBar = (espaciado * 0.55).clamp(2.0, 8.0);
return Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: List.generate(widget.barras, (i) {
final b = _barras[i];
final double altura;
if (_activo) {
// Movimiento orgánico: combinación de senos con diferentes fases
final onda1 = sin(t * b.velocidad + b.fase);
final onda2 = sin(t * b.velocidad * 0.7 + b.fase * 1.3) * 0.5;
final valor = ((onda1 + onda2 + 1.5) / 3.0).clamp(0.0, 1.0);
altura = (b.offset + valor * b.amplitud) * widget.altura;
} else {
// Decaer suavemente a altura mínima
final progreso = _controller.value;
final alturaActual = b.alturaActual;
b.alturaActual = alturaActual * (1 - progreso * 0.1);
altura = b.alturaActual.clamp(2.0, widget.altura * 0.05);
}
return Padding(
padding: EdgeInsets.symmetric(horizontal: (espaciado - anchoBar) / 2),
child: AnimatedContainer(
duration: const Duration(milliseconds: 80),
width: anchoBar,
height: altura.clamp(2.0, widget.altura),
decoration: BoxDecoration(
color: color.withValues(
alpha: _activo ? 0.7 + (altura / widget.altura) * 0.3 : 0.3,
),
borderRadius: BorderRadius.circular(anchoBar / 2),
),
),
);
}),
);
},
),
);
}
}
class _BarraState {
final double fase;
final double velocidad;
final double amplitud;
final double offset;
double alturaActual;
_BarraState({
required this.fase,
required this.velocidad,
required this.amplitud,
required this.offset,
}) : alturaActual = offset * 20;
}
/// Versión compacta del visualizador — 5 barras, para uso en MiniReproductor
/// o indicadores pequeños de "en reproducción".
class IndicadorReproduccion extends StatefulWidget {
final Stream<EstadoReproduccion> estadoStream;
final Color? color;
final double size;
const IndicadorReproduccion({
super.key,
required this.estadoStream,
this.color,
this.size = 16,
});
@override
State<IndicadorReproduccion> createState() => _IndicadorReproduccionState();
}
class _IndicadorReproduccionState extends State<IndicadorReproduccion>
with SingleTickerProviderStateMixin {
late AnimationController _ctrl;
bool _reproduciendo = false;
@override
void initState() {
super.initState();
_ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 600))
..addListener(() => setState(() {}));
widget.estadoStream.listen((s) {
final rep = s == EstadoReproduccion.reproduciendo;
if (rep == _reproduciendo) return;
setState(() => _reproduciendo = rep);
rep ? _ctrl.repeat(reverse: true) : _ctrl.stop();
});
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final color = widget.color ??
Theme.of(context).colorScheme.primary;
if (!_reproduciendo) {
return Icon(Icons.radio, size: widget.size,
color: Theme.of(context).colorScheme.onSurfaceVariant);
}
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: List.generate(3, (i) {
final alts = [0.5, 1.0, 0.7];
final fases = [0.0, 0.3, 0.6];
final h = ((sin(_ctrl.value * pi + fases[i]) + 1) / 2 * alts[i] + 0.2)
.clamp(0.15, 1.0) * widget.size;
return Container(
width: widget.size * 0.2,
height: h,
margin: EdgeInsets.only(right: i < 2 ? widget.size * 0.1 : 0),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(1),
),
);
}),
);
}
}

View File

@@ -9,38 +9,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.13.1"
audio_service:
dependency: "direct main"
description:
name: audio_service
sha256: cb122c7c2639d2a992421ef96b67948ad88c5221da3365ccef1031393a76e044
url: "https://pub.dev"
source: hosted
version: "0.18.18"
audio_service_platform_interface:
dependency: transitive
description:
name: audio_service_platform_interface
sha256: "6283782851f6c8b501b60904a32fc7199dc631172da0629d7301e66f672ab777"
url: "https://pub.dev"
source: hosted
version: "0.1.3"
audio_service_web:
dependency: transitive
description:
name: audio_service_web
sha256: b8ea9243201ee53383157fbccf13d5d2a866b5dda922ec19d866d1d5d70424df
url: "https://pub.dev"
source: hosted
version: "0.1.4"
audio_session:
dependency: "direct main"
description:
name: audio_session
sha256: "2b7fff16a552486d078bfc09a8cde19f426dc6d6329262b684182597bec5b1ac"
url: "https://pub.dev"
source: hosted
version: "0.1.25"
boolean_selector:
dependency: transitive
description:
@@ -49,30 +17,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.2"
cached_network_image:
dependency: "direct main"
description:
name: cached_network_image
sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916"
url: "https://pub.dev"
source: hosted
version: "3.4.1"
cached_network_image_platform_interface:
dependency: transitive
description:
name: cached_network_image_platform_interface
sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829"
url: "https://pub.dev"
source: hosted
version: "4.1.1"
cached_network_image_web:
dependency: transitive
description:
name: cached_network_image_web
sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062"
url: "https://pub.dev"
source: hosted
version: "1.3.1"
characters:
dependency: transitive
description:
@@ -89,14 +33,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.2"
code_assets:
dependency: transitive
description:
name: code_assets
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
collection:
dependency: transitive
description:
@@ -105,22 +41,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.19.1"
cross_file:
dependency: transitive
description:
name: cross_file
sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
url: "https://pub.dev"
source: hosted
version: "0.3.5+2"
crypto:
dependency: transitive
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
cupertino_icons:
dependency: "direct main"
description:
@@ -137,178 +57,24 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
file_picker:
dependency: "direct main"
description:
name: file_picker
sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810
url: "https://pub.dev"
source: hosted
version: "8.3.7"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_animate:
dependency: "direct main"
description:
name: flutter_animate
sha256: "7befe2d3252728afb77aecaaea1dec88a89d35b9b1d2eea6d04479e8af9117b5"
url: "https://pub.dev"
source: hosted
version: "4.5.2"
flutter_cache_manager:
dependency: transitive
description:
name: flutter_cache_manager
sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386"
url: "https://pub.dev"
source: hosted
version: "3.4.1"
flutter_lints:
dependency: "direct dev"
description:
name: flutter_lints
sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1"
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
url: "https://pub.dev"
source: hosted
version: "5.0.0"
flutter_localizations:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0"
url: "https://pub.dev"
source: hosted
version: "2.0.34"
flutter_shaders:
dependency: transitive
description:
name: flutter_shaders
sha256: "34794acadd8275d971e02df03afee3dee0f98dbfb8c4837082ad0034f612a3e2"
url: "https://pub.dev"
source: hosted
version: "0.1.3"
version: "6.0.0"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
google_fonts:
dependency: "direct main"
description:
name: google_fonts
sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055
url: "https://pub.dev"
source: hosted
version: "6.3.3"
hooks:
dependency: transitive
description:
name: hooks
sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388
url: "https://pub.dev"
source: hosted
version: "1.0.2"
http:
dependency: "direct main"
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
intl:
dependency: transitive
description:
name: intl
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
url: "https://pub.dev"
source: hosted
version: "0.20.2"
js:
dependency: transitive
description:
name: js
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
url: "https://pub.dev"
source: hosted
version: "0.7.2"
just_audio:
dependency: "direct main"
description:
name: just_audio
sha256: f978d5b4ccea08f267dae0232ec5405c1b05d3f3cd63f82097ea46c015d5c09e
url: "https://pub.dev"
source: hosted
version: "0.9.46"
just_audio_platform_interface:
dependency: transitive
description:
name: just_audio_platform_interface
sha256: "2532c8d6702528824445921c5ff10548b518b13f808c2e34c2fd54793b999a6a"
url: "https://pub.dev"
source: hosted
version: "4.6.0"
just_audio_web:
dependency: transitive
description:
name: just_audio_web
sha256: "6ba8a2a7e87d57d32f0f7b42856ade3d6a9fbe0f1a11fabae0a4f00bb73f0663"
url: "https://pub.dev"
source: hosted
version: "0.4.16"
leak_tracker:
dependency: transitive
description:
@@ -337,18 +103,10 @@ packages:
dependency: transitive
description:
name: lints
sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
url: "https://pub.dev"
source: hosted
version: "5.1.1"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
version: "6.1.0"
matcher:
dependency: transitive
description:
@@ -373,46 +131,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.17.0"
mime:
dependency: transitive
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
native_toolchain_c:
dependency: transitive
description:
name: native_toolchain_c
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
url: "https://pub.dev"
source: hosted
version: "0.17.6"
nested:
dependency: transitive
description:
name: nested
sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
url: "https://pub.dev"
source: hosted
version: "9.3.0"
octo_image:
dependency: transitive
description:
name: octo_image
sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
path:
dependency: transitive
description:
@@ -421,174 +139,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_provider:
dependency: "direct main"
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://pub.dev"
source: hosted
version: "2.1.5"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "149441ca6e4f38193b2e004c0ca6376a3d11f51fa5a77552d8bd4d2b0c0912ba"
url: "https://pub.dev"
source: hosted
version: "2.2.23"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
url: "https://pub.dev"
source: hosted
version: "2.6.0"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
url: "https://pub.dev"
source: hosted
version: "2.2.1"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
provider:
dependency: "direct main"
description:
name: provider
sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272"
url: "https://pub.dev"
source: hosted
version: "6.1.5+1"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
rxdart:
dependency: transitive
description:
name: rxdart
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
url: "https://pub.dev"
source: hosted
version: "0.28.0"
share_plus:
dependency: "direct main"
description:
name: share_plus
sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da
url: "https://pub.dev"
source: hosted
version: "10.1.4"
share_plus_platform_interface:
dependency: transitive
description:
name: share_plus_platform_interface
sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b
url: "https://pub.dev"
source: hosted
version: "5.0.2"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
url: "https://pub.dev"
source: hosted
version: "2.5.5"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53
url: "https://pub.dev"
source: hosted
version: "2.4.23"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
url: "https://pub.dev"
source: hosted
version: "2.5.6"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.dev"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shimmer:
dependency: "direct main"
description:
name: shimmer
sha256: "5f88c883a22e9f9f299e5ba0e4f7e6054857224976a5d9f839d4ebdc94a14ac9"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
sky_engine:
dependency: transitive
description: flutter
@@ -602,46 +152,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.10.2"
sqflite:
dependency: "direct main"
description:
name: sqflite
sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03
url: "https://pub.dev"
source: hosted
version: "2.4.2"
sqflite_android:
dependency: transitive
description:
name: sqflite_android
sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40"
url: "https://pub.dev"
source: hosted
version: "2.4.2+3"
sqflite_common:
dependency: transitive
description:
name: sqflite_common
sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6"
url: "https://pub.dev"
source: hosted
version: "2.5.6"
sqflite_darwin:
dependency: transitive
description:
name: sqflite_darwin
sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
sqflite_platform_interface:
dependency: transitive
description:
name: sqflite_platform_interface
sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920"
url: "https://pub.dev"
source: hosted
version: "2.4.0"
stack_trace:
dependency: transitive
description:
@@ -666,14 +176,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.1"
synchronized:
dependency: transitive
description:
name: synchronized
sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
url: "https://pub.dev"
source: hosted
version: "3.4.0"
term_glyph:
dependency: transitive
description:
@@ -690,86 +192,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.9"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
url_launcher:
dependency: "direct main"
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
url: "https://pub.dev"
source: hosted
version: "6.3.2"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572"
url: "https://pub.dev"
source: hosted
version: "6.3.29"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
url: "https://pub.dev"
source: hosted
version: "6.4.1"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
url: "https://pub.dev"
source: hosted
version: "3.2.2"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
url: "https://pub.dev"
source: hosted
version: "3.2.5"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f
url: "https://pub.dev"
source: hosted
version: "2.4.2"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
url: "https://pub.dev"
source: hosted
version: "3.1.5"
uuid:
dependency: "direct main"
description:
name: uuid
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
url: "https://pub.dev"
source: hosted
version: "4.5.3"
vector_math:
dependency: transitive
description:
@@ -786,38 +208,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "15.0.2"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
win32:
dependency: transitive
description:
name: win32
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
url: "https://pub.dev"
source: hosted
version: "5.15.0"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.10.3 <4.0.0"
flutter: ">=3.38.4"
dart: ">=3.11.1 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54"

View File

@@ -37,8 +37,6 @@ dependencies:
# Utils
share_plus: ^10.1.3
file_picker: ^8.1.7
uuid: ^4.5.1
url_launcher: ^6.3.1
# Ads (activar cuando tengamos Ad Unit IDs)