5 Commits

Author SHA1 Message Date
Kira (Agent)
e9d1f67aa4 feat(mvp): PluriWave Fase 1 — estructura completa de la app
Some checks failed
Flutter CI/CD — PluriWave / Test + Build (pull_request) Has been cancelled
- Modelo Emisora: campos completos Radio Browser API (fromApi + fromMap)
- ServicioRadio: cliente Radio Browser API (populares, tendencias, buscar por nombre/país/idioma/tag)
- ServicioAudio: just_audio + audio_service wrapper (play/pause/stop/toggle, fade, background handler)
- ServicioTimer: countdown con fade out gradual (15/30/60/90 min)
- ServicioFavoritos: actualizado a v2 con campos codec/bitrate/votes/clickcount
- EstadoRadio: ChangeNotifier global con Provider
- PantallaInicio: grid emisoras populares, chips género, shimmer loading, pull-to-refresh
- PantallaBuscar: SearchBar + filtros país/idioma, lista resultados
- PantallaFavoritos: ReorderableListView + swipe-to-delete (Dismissible)
- TarjetaEmisora: card + modo compacto ListTile, cached_network_image, shimmer fallback
- MiniReproductor: barra inferior persistente con stream de estado
- app.dart: MaterialApp + Provider + NavigationBar + timer dialog
- main.dart: punto de entrada limpio
- AndroidManifest.xml: permisos INTERNET + FOREGROUND_SERVICE + audio_service receivers
2026-04-04 17:15:18 +02:00
ShanaiaBot
25a3f3cf5a merge: incorporar modelo Emisora y ServicioFavoritos desde feature/bd-favoritos 2026-04-04 17:10:18 +02:00
agent-arq
2fe1d60e23 docs: update README — sección CI/CD, secrets, signing (PR#2)
Some checks failed
Flutter CI/CD — PluriWave / Test + Build (push) Has been cancelled
2026-04-04 16:47:10 +02:00
agent-arq
76f1b4ce2d docs: create CHANGELOG — v0.2.0 CI/CD Gitea Actions + revisión arquitectura F1 (PR#2)
Some checks failed
Flutter CI/CD — PluriWave / Test + Build (push) Has been cancelled
2026-04-04 16:46:45 +02:00
agent-arq
64f6e37373 feat(ci): Gitea Actions CI/CD Flutter + revisión arquitectura F1 (#2)
Some checks failed
Flutter CI/CD — PluriWave / Test + Build (push) Has been cancelled
2026-04-04 16:44:04 +02:00
18 changed files with 1855 additions and 350 deletions

66
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,66 @@
name: Flutter CI/CD — PluriWave
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
flutter-ci:
name: Test + Build
runs-on: macmini-flutter
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Flutter pub get
run: flutter pub get
- name: Run tests
run: flutter test
- name: Build APK (release)
run: flutter build apk --release
- name: Build AppBundle (release)
run: flutter build appbundle --release
- name: Upload APK artifact
uses: actions/upload-artifact@v4
with:
name: pluriwave-apk-${{ gitea.sha }}
path: build/app/outputs/apk/release/app-release.apk
if-no-files-found: error
- name: Upload AppBundle artifact
uses: actions/upload-artifact@v4
with:
name: pluriwave-aab-${{ gitea.sha }}
path: build/app/outputs/bundle/release/app-release.aab
if-no-files-found: error
- name: Notify Telegram — éxito
if: success()
run: |
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-d chat_id="${TELEGRAM_CHAT_ID}" \
-d parse_mode="HTML" \
-d text="✅ <b>PluriWave CI OK</b>%0ABranch: <code>${{ gitea.ref_name }}</code>%0ACommit: <code>${{ gitea.sha }}</code>%0AAPKs subidos como artifacts en Gitea."
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
- name: Notify Telegram — fallo
if: failure()
run: |
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-d chat_id="${TELEGRAM_CHAT_ID}" \
-d parse_mode="HTML" \
-d text="❌ <b>PluriWave CI FALLÓ</b>%0ABranch: <code>${{ gitea.ref_name }}</code>%0ACommit: <code>${{ gitea.sha }}</code>%0ARevisa el log en Gitea."
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}

143
ARQ-REVISION-F1.md Normal file
View File

@@ -0,0 +1,143 @@
# Revisión Arquitectura — PluriWave Fase 1
**Arq | 2026-04-04**
---
## Decisión: stack APROBADO con ajustes menores
El stack propuesto es sólido para una app de radio Flutter. Sin conflictos bloqueantes de dependencias. Sin problemas de licencia.
---
## Stack analizado
| Paquete | Versión declarada | Latest | Licencia |
|---------|------------------|--------|----------|
| `just_audio` | `^0.9.42` | 0.10.5 | Apache-2.0 + MIT ✅ |
| `audio_service` | `^0.18.15` | 0.18.18 | MIT ✅ |
| `audio_session` | `^0.1.21` | 0.2.3 | N/A (OSI) ✅ |
| `sqflite` | `^2.4.1` | 2.4.2 | BSD-2 ✅ |
| `flutter_animate` | `^4.5.2` | 4.5.2 | BSD-3 ✅ |
| `google_mobile_ads` | comentado | 7.0.0 | Apache-2.0 ✅ |
---
## Análisis de compatibilidad de versiones
### Dependencia crítica: `audio_session`
Tres paquetes compiten por `audio_session`:
- `just_audio 0.9.42` requiere: `^0.1.14` → ≥0.1.14 <0.2.0
- `audio_service 0.18.15` requiere: `^0.1.20` → ≥0.1.20 <0.2.0
- `pubspec.yaml` declara: `^0.1.21` → ≥0.1.21 <0.2.0
**Intersección**: ≥0.1.21 <0.2.0 → disponibles hasta 0.1.25 → **SIN CONFLICTO ✅**
### `rxdart`
Ambos paquetes requieren `>=0.26.0 <0.29.0`. rxdart latest es 0.28.0. **SIN CONFLICTO ✅**
### `js` (dep transitiva de `audio_service`)
`audio_service` requiere `js >=0.6.3 <0.8.0`. Esto es solo para la plataforma web. Si no hay web target, es irrelevante. Si en el futuro se añade web: aceptable, `js 0.7.2` es la latest en ese rango.
---
## Ajustes recomendados
### 🟠 1. Actualizar `just_audio` a `^0.10.0` (importante)
`just_audio 0.9.x` está en mantenimiento. La rama `0.10.x` (latest: 0.10.5) tiene:
- Soporte Flutter ≥3.27.0 (el runner macmini-flutter probablemente lo cumple)
- API compatible para streaming de radio (URLs HTTP/HTTPS directas)
- `audio_session` requiere `>=0.1.24 <0.3.0` con 0.10.x — compatible con la gama completa
**Acción**: cambiar `just_audio: ^0.9.42``just_audio: ^0.10.0` en pubspec.yaml.
Si el runner tiene Flutter <3.27.0, mantener 0.9.42.
### 🟡 2. Actualizar `audio_service` a `^0.18.18` (menor)
La 0.18.18 actualiza `audio_session` a `>=0.1.25 <0.3.0`, lo que permite usar versiones 0.2.x en el futuro sin romper nada.
### 🟡 3. `google_mobile_ads` — descomentarlo con cuidado
Actualmente comentado (correcto para Fase 1). Cuando se active:
- Usar `^5.3.0` (ya declarado) si el runner tiene Flutter ≥3.7.0 — ✅
- **Evitar `^7.0.0`** por ahora: requiere actualización de `compileSdk` en Android y puede romper el CI
- Necesitará `ad_unit_id` real en secrets de CI antes de activar en release builds
- Play Store requiere declarar uso de datos de anuncios en el Data Safety form
### 🔴 4. Signing config para release builds — BLOQUEANTE para CI
`android/app/build.gradle.kts` tiene:
```kotlin
signingConfig = signingConfigs.getByName("debug") // TODO: fix
```
Esto firma el APK/AAB release con la clave debug. **No es aceptable para Play Store**, pero sí funciona para artifacts internos de CI (distribución interna, testing).
Para CI de Fase 1: aceptable como está.
Para producción: añadir keystore como secret en Gitea CI y configurar signing real.
---
## Estructura de app — observación
`lib/main.dart` es el scaffold por defecto de Flutter (contador demo). Los devs deberán reemplazarlo con la arquitectura real de PluriWave antes de que los tests sean útiles.
Sugerencia de estructura para Fase 1:
```
lib/
├── main.dart
├── app.dart # MaterialApp + Provider setup
├── core/
│ ├── servicios/
│ │ ├── servicio_audio.dart # just_audio + audio_service wrapper
│ │ └── servicio_radio.dart # fetch streams/stations API
│ └── db/
│ └── db_helper.dart # sqflite setup
├── modelos/
│ ├── emisora.dart
│ └── favorito.dart
└── ui/
├── pantalla_inicio.dart
├── pantalla_reproductor.dart
└── widgets/
```
`Provider` (ya en pubspec) es correcto para este scope. No hace falta Riverpod ni Bloc para Fase 1.
---
## Licencias — veredicto
| Paquete | Licencia | Uso comercial | Distribución |
|---------|----------|--------------|-------------|
| `just_audio` | Apache-2.0 + MIT | ✅ libre | ✅ libre |
| `audio_service` | MIT | ✅ libre | ✅ libre |
| `sqflite` | BSD-2 | ✅ libre | ✅ libre |
| `flutter_animate` | BSD-3 | ✅ libre | ✅ libre |
| `google_mobile_ads` | Apache-2.0 | ✅ (con cuenta AdMob) | ✅ libre |
Sin restricciones de licencia para app comercial en Play Store. ✅
---
## Resumen ejecutivo
| Ítem | Estado |
|------|--------|
| Conflictos de dependencias | ✅ Ninguno |
| Licencias incompatibles | ✅ Ninguna |
| Stack adecuado para radio en streaming | ✅ Sí |
| Signing release para Play Store | ⚠️ Pendiente (keystore) |
| just_audio versión óptima | 🟠 Actualizar a ^0.10.0 si Flutter ≥3.27 |
| google_mobile_ads | 🟡 Descomentarlo solo cuando haya Ad Unit IDs reales |
| Estructura de código | 🟡 Scaffold vacío — devs deben estructurar lib/ |
**El stack puede profundizarse sin riesgo. Sin bloqueos.**
---
*Arq — revisión sin acceso al analisis.md de Obsidian (fichero no sincronizado localmente). Revisión realizada directamente desde pub.dev API + pubspec.yaml del repo.*

18
CHANGELOG.md Normal file
View File

@@ -0,0 +1,18 @@
# Changelog — PluriWave
## [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** — accede rápido a tus 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** — acceso rápido a emisoras preferidas
- **Compartir** — envía emisoras a tus amigos
- **UI premium** — Material You, visualizador de audio, animaciones fluidas
## Monetización
@@ -22,13 +22,31 @@ Radio mundial con ecualizador personalizable, reconocimiento de canciones y UI p
## Stack
- **Frontend**: Flutter (Android + iOS)
- **Radio API**: [Radio Browser](https://api.radio-browser.info/) (gratis, +53K emisoras)
- **Radio API**: Radio Browser (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)
@@ -36,3 +54,4 @@ FreeTimeLab — [freetimelab.es](https://freetimelab.es)
## Licencia
MIT

View File

@@ -1,6 +1,12 @@
<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">
<activity
@@ -12,10 +18,6 @@
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"
@@ -25,17 +27,30 @@
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<!-- 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>
<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"/>

163
lib/app.dart Normal file
View File

@@ -0,0 +1,163 @@
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 '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), // Morado Material You
brightness: brightness,
);
return ThemeData(
useMaterial3: true,
colorScheme: colorScheme,
textTheme: GoogleFonts.interTextTheme(
ThemeData(brightness: brightness).textTheme,
),
cardTheme: CardTheme(
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
color: colorScheme.surfaceContainerLow,
),
);
}
}
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(),
];
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',
),
];
@override
Widget build(BuildContext context) {
return Scaffold(
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 m = t.inMinutes.remainder(60).toString().padLeft(2, '0');
final s = t.inSeconds.remainder(60).toString().padLeft(2, '0');
return Column(
children: [
Text('${t.inHours > 0 ? "${t.inHours}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

@@ -0,0 +1,136 @@
import 'package:flutter/foundation.dart';
import '../modelos/emisora.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).
///
/// Centraliza: reproductoor, favoritos, búsqueda, timer.
class EstadoRadio extends ChangeNotifier {
final ServicioAudio audio = ServicioAudio();
final ServicioFavoritos favoritos = ServicioFavoritos();
final ServicioRadio radio = ServicioRadio();
late final ServicioTimer timer;
List<Emisora> _populares = [];
List<Emisora> _tendencias = [];
List<Emisora> _resultadosBusqueda = [];
List<Emisora> _listafavoritos = [];
bool _cargandoPopulares = false;
bool _cargandoBusqueda = false;
String? _error;
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;
bool get cargandoPopulares => _cargandoPopulares;
bool get cargandoBusqueda => _cargandoBusqueda;
String? get error => _error;
Emisora? get emisoraActual => audio.emisoraActual;
Stream<EstadoReproduccion> get estadoStream => audio.estadoStream;
Future<void> _init() async {
await Future.wait([
cargarPopulares(),
cargarFavoritos(),
]);
}
Future<void> cargarPopulares() async {
_cargandoPopulares = true;
_error = null;
notifyListeners();
try {
final results = await Future.wait([
radio.obtenerPopulares(limit: 30),
radio.obtenerTendencias(limit: 20),
]);
_populares = results[0];
_tendencias = results[1];
} catch (e) {
_error = 'Error al cargar emisoras: $e';
} 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) {
_error = 'Error en búsqueda: $e';
} finally {
_cargandoBusqueda = false;
notifyListeners();
}
}
Future<void> reproducir(Emisora emisora) async {
try {
await audio.reproducir(emisora);
radio.registrarClick(emisora.uuid); // fire & forget
notifyListeners();
} catch (e) {
_error = 'No se puede reproducir esta emisora';
notifyListeners();
}
}
Future<void> togglePlay() async {
await audio.togglePlay();
notifyListeners();
}
Future<bool> toggleFavorito(Emisora emisora) async {
final esFav = await favoritos.toggleFavorito(emisora);
await cargarFavoritos();
return esFav;
}
Future<bool> esFavorito(String uuid) => favoritos.esFavorito(uuid);
void iniciarTimer(int minutos) {
timer.iniciar(minutos);
notifyListeners();
}
void cancelarTimer() {
timer.cancelar();
notifyListeners();
}
@override
void dispose() {
audio.dispose();
timer.dispose();
super.dispose();
}
}

View File

@@ -1,122 +1,7 @@
import 'package:flutter/material.dart';
import 'app.dart';
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),
),
);
}
WidgetsFlutterBinding.ensureInitialized();
runApp(const PluriWaveApp());
}

View File

@@ -1,8 +1,8 @@
/// Modelo de datos de una emisora de radio.
///
/// Representa una emisora favorita almacenada en SQLite.
/// Los campos opcionales (favicon, pais, idioma, tags) pueden ser null
/// cuando la emisora no dispone de esa información.
/// 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;
@@ -10,8 +10,13 @@ class Emisora {
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({
@@ -21,11 +26,34 @@ class Emisora {
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(
@@ -35,14 +63,18 @@ class Emisora {
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 la emisora para inserción/actualización en SQLite.
/// No incluye [id] — lo gestiona la BD.
/// Serializa para inserción/actualización en SQLite.
Map<String, dynamic> toMap() {
return {
'uuid': uuid,
@@ -50,13 +82,17 @@ class Emisora {
'url': url,
'favicon': favicon,
'pais': pais,
'codigo_pais': codigoPais,
'idioma': idioma,
'tags': tags,
'codec': codec,
'bitrate': bitrate,
'votes': votes,
'clickcount': clickcount,
'orden': orden,
};
}
/// Devuelve una copia con los campos indicados modificados.
Emisora copyWith({
int? id,
String? uuid,
@@ -64,8 +100,13 @@ class Emisora {
String? url,
String? favicon,
String? pais,
String? codigoPais,
String? idioma,
String? tags,
String? codec,
int? bitrate,
int? votes,
int? clickcount,
int? orden,
}) {
return Emisora(
@@ -75,22 +116,33 @@ class Emisora {
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(id: $id, uuid: $uuid, nombre: $nombre, orden: $orden)';
String toString() => 'Emisora(uuid: $uuid, nombre: $nombre)';
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Emisora &&
runtimeType == other.runtimeType &&
uuid == other.uuid;
other is Emisora && runtimeType == other.runtimeType && uuid == other.uuid;
@override
int get hashCode => uuid.hashCode;

View File

@@ -0,0 +1,180 @@
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

@@ -0,0 +1,75 @@
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

@@ -0,0 +1,214 @@
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

@@ -0,0 +1,175 @@
import 'package:audio_service/audio_service.dart';
import 'package:just_audio/just_audio.dart';
import '../modelos/emisora.dart';
/// Estado de reproducción expuesto al UI.
enum EstadoReproduccion { detenido, cargando, reproduciendo, pausado, error }
/// Wrapper sobre just_audio + audio_service para reproducción de radio en streaming.
///
/// ### Uso
/// ```dart
/// final servicio = ServicioAudio();
/// await servicio.inicializar();
/// await servicio.reproducir(emisora);
/// await servicio.pausar();
/// await servicio.detener();
/// ```
///
/// ### Background audio
/// Para habilitar reproducción en background, el handler [PluriWaveAudioHandler]
/// debe registrarse en main.dart con [AudioService.init]. Si no está registrado,
/// just_audio seguirá funcionando en foreground.
class ServicioAudio {
final AudioPlayer _player = AudioPlayer();
Emisora? _emisoraActual;
EstadoReproduccion _estado = EstadoReproduccion.detenido;
EstadoReproduccion get estado => _estado;
Emisora? get emisoraActual => _emisoraActual;
/// Stream de cambios de estado para el UI.
Stream<EstadoReproduccion> get estadoStream => _player.playerStateStream.map(
(s) {
if (s.processingState == ProcessingState.loading ||
s.processingState == ProcessingState.buffering) {
return EstadoReproduccion.cargando;
}
if (s.playing) return EstadoReproduccion.reproduciendo;
if (s.processingState == ProcessingState.idle) return EstadoReproduccion.detenido;
return EstadoReproduccion.pausado;
},
);
/// Inicia la reproducción de la [emisora] indicada.
Future<void> reproducir(Emisora emisora) async {
try {
_estado = EstadoReproduccion.cargando;
// Si es la misma emisora, reanudar sin recargar
if (_emisoraActual?.uuid == emisora.uuid && _player.audioSource != null) {
await _player.play();
_estado = EstadoReproduccion.reproduciendo;
return;
}
_emisoraActual = emisora;
await _player.stop();
await _player.setUrl(emisora.url);
await _player.play();
_estado = EstadoReproduccion.reproduciendo;
} on PlayerException catch (_) {
_estado = EstadoReproduccion.error;
rethrow;
} catch (e) {
_estado = EstadoReproduccion.error;
rethrow;
}
}
/// Pausa la reproducción actual.
Future<void> pausar() async {
await _player.pause();
_estado = EstadoReproduccion.pausado;
}
/// Reanuda si estaba pausado.
Future<void> reanudar() async {
if (_player.audioSource != null) {
await _player.play();
_estado = EstadoReproduccion.reproduciendo;
}
}
/// Alterna entre pausa y reproducción.
Future<void> togglePlay() async {
if (_player.playing) {
await pausar();
} else {
await reanudar();
}
}
/// Detiene la reproducción y libera la fuente.
Future<void> detener() async {
await _player.stop();
_emisoraActual = null;
_estado = EstadoReproduccion.detenido;
}
/// Ajusta el volumen (0.0 - 1.0).
Future<void> setVolumen(double volumen) async {
await _player.setVolume(volumen.clamp(0.0, 1.0));
}
double get volumen => _player.volume;
bool get estaSonando => _player.playing;
/// Libera recursos. Llamar al destruir la pantalla raíz.
Future<void> dispose() async {
await _player.dispose();
}
}
/// Handler de audio_service para reproducción en background con notificación.
///
/// Registrar en main.dart:
/// ```dart
/// final handler = await AudioService.init(
/// builder: () => PluriWaveAudioHandler(),
/// config: const AudioServiceConfig(
/// androidNotificationChannelId: 'es.freetimelab.pluriwave.audio',
/// androidNotificationChannelName: 'PluriWave Radio',
/// androidNotificationOngoing: true,
/// androidStopForegroundOnPause: true,
/// ),
/// );
/// ```
class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
final AudioPlayer _player = AudioPlayer();
PluriWaveAudioHandler() {
_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},
androidCompactActionIndices: const [0],
processingState: {
ProcessingState.idle: AudioProcessingState.idle,
ProcessingState.loading: AudioProcessingState.loading,
ProcessingState.buffering: AudioProcessingState.buffering,
ProcessingState.ready: AudioProcessingState.ready,
ProcessingState.completed: AudioProcessingState.completed,
}[proc]!,
playing: playing,
));
});
}
@override
Future<void> playMediaItem(MediaItem item) async {
mediaItem.add(item);
await _player.setUrl(item.id);
await _player.play();
}
@override
Future<void> play() => _player.play();
@override
Future<void> pause() => _player.pause();
@override
Future<void> stop() async {
await _player.stop();
await super.stop();
}
@override
Future<void> seek(Duration position) => _player.seek(position);
}

View File

@@ -1,251 +1,125 @@
import 'package:path/path.dart';
import 'package:sqflite/sqflite.dart';
import '../modelos/emisora.dart';
/// Servicio de persistencia de emisoras favoritas con SQLite (sqflite).
/// Servicio de persistencia de emisoras favoritas con SQLite.
///
/// ### Inicialización lazy
/// La base de datos se abre en el primer acceso; no es necesario llamar
/// a ningún método `init()` explícito.
///
/// ### Migration-ready
/// El esquema está versionado (`_dbVersion`). Para añadir columnas en una
/// versión futura, implementa el caso correspondiente en `_onUpgrade`.
/// La versión actual es **1**.
///
/// ### Eliminación lógica
/// No se borran filas físicas sin confirmación explícita. El método
/// [eliminar] hace `DELETE` por `uuid` (la tabla de favoritos es propiedad
/// del usuario y el borrado es una acción explícita y reversible vía
/// [agregar]). Si en el futuro se requiere eliminación lógica, añade la
/// columna `eliminado` en la migración a versión 2.
///
/// ### Uso básico
/// ```dart
/// final servicio = ServicioFavoritos();
///
/// await servicio.agregar(emisora);
/// final lista = await servicio.obtenerTodos();
/// final esFav = await servicio.esFavorito('some-uuid');
/// await servicio.reordenar('some-uuid', 3);
/// await servicio.eliminar('some-uuid');
/// ```
/// - 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 {
// ─── Constantes de esquema ────────────────────────────────────────────────
static const String _dbNombre = 'pluriwave.db';
static const int _dbVersion = 1;
static const String _tabla = 'favoritos';
// Columnas
static const String _colId = 'id';
static const String _colUuid = 'uuid';
static const String _colNombre = 'nombre';
static const String _colUrl = 'url';
static const String _colFavicon = 'favicon';
static const String _colPais = 'pais';
static const String _colIdioma = 'idioma';
static const String _colTags = 'tags';
static const String _colOrden = 'orden';
// ─── Estado interno ───────────────────────────────────────────────────────
/// Instancia única del servicio (singleton ligero).
static final ServicioFavoritos _instancia = ServicioFavoritos._interno();
factory ServicioFavoritos() => _instancia;
ServicioFavoritos._interno();
static const _dbName = 'pluriwave.db';
static const _dbVersion = 2;
Database? _db;
// ─── Acceso a la BD (lazy) ────────────────────────────────────────────────
/// Devuelve la instancia abierta de la BD.
/// La abre y crea las tablas en el primer acceso.
Future<Database> get _database async {
_db ??= await _abrirBd();
_db ??= await _initDb();
return _db!;
}
Future<Database> _abrirBd() async {
final ruta = join(await getDatabasesPath(), _dbNombre);
Future<Database> _initDb() async {
final dbPath = await getDatabasesPath();
final path = join(dbPath, _dbName);
return openDatabase(
ruta,
path,
version: _dbVersion,
onCreate: _onCreate,
onUpgrade: _onUpgrade,
);
}
// ─── Callbacks de ciclo de vida de la BD ─────────────────────────────────
/// Crea el esquema inicial (versión 1).
Future<void> _onCreate(Database db, int version) async {
await db.execute('''
CREATE TABLE IF NOT EXISTS $_tabla (
$_colId INTEGER PRIMARY KEY AUTOINCREMENT,
$_colUuid TEXT NOT NULL UNIQUE,
$_colNombre TEXT NOT NULL,
$_colUrl TEXT NOT NULL,
$_colFavicon TEXT,
$_colPais TEXT,
$_colIdioma TEXT,
$_colTags TEXT,
$_colOrden INTEGER NOT NULL DEFAULT 0
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
)
''');
// Índice para búsquedas frecuentes por uuid
await db.execute(
'CREATE UNIQUE INDEX IF NOT EXISTS idx_favoritos_uuid ON $_tabla ($_colUuid)',
);
// Índice para ordenación
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_favoritos_orden ON $_tabla ($_colOrden)',
);
}
/// Migraciones futuras.
///
/// Añade un `case` por cada nueva versión. No uses `DROP COLUMN` —
/// marca la columna como obsoleta y elimínala en el siguiente sprint.
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
for (var v = oldVersion + 1; v <= newVersion; v++) {
switch (v) {
// Ejemplo para versión 2:
// case 2:
// await db.execute(
// 'ALTER TABLE $_tabla ADD COLUMN nueva_col TEXT',
// );
default:
break;
}
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');
}
}
// ─── API pública ──────────────────────────────────────────────────────────
/// Devuelve todas las emisoras favoritas ordenadas por [_colOrden] ASC.
///
/// Nunca devuelve null; devuelve lista vacía si no hay favoritos.
/// Devuelve todas las emisoras favoritas ordenadas por [orden].
Future<List<Emisora>> obtenerTodos() async {
final db = await _database;
final filas = await db.query(
_tabla,
orderBy: '$_colOrden ASC, $_colId ASC',
);
return filas.map(Emisora.fromMap).toList();
final rows = await db.query('favoritos', orderBy: 'orden ASC');
return rows.map(Emisora.fromMap).toList();
}
/// Agrega [emisora] a la lista de favoritos.
///
/// Si ya existe una emisora con el mismo [Emisora.uuid], actualiza todos
/// sus campos (upsert). El campo [Emisora.orden] se asigna al final de la
/// lista cuando su valor es 0 y la emisora es nueva.
///
/// Devuelve el [id] de la fila insertada o actualizada.
Future<int> agregar(Emisora emisora) async {
/// Añade una emisora a favoritos. Si ya existe (mismo uuid), la actualiza.
Future<void> agregar(Emisora emisora) async {
final db = await _database;
// Calcular orden automático si viene en 0 y la emisora es nueva
Emisora emisoraFinal = emisora;
if (emisora.orden == 0) {
final existe = await esFavorito(emisora.uuid);
if (!existe) {
final maxOrden = await _maxOrden(db);
emisoraFinal = emisora.copyWith(orden: maxOrden + 1);
}
}
return db.insert(
_tabla,
emisoraFinal.toMap(),
// 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 la emisora con [uuid] de la lista de favoritos.
///
/// Si la emisora no existe, no hace nada (idempotente).
/// Devuelve el número de filas eliminadas (0 ó 1).
Future<int> eliminar(String uuid) async {
/// Elimina una emisora de favoritos por [uuid].
Future<void> eliminar(String uuid) async {
final db = await _database;
return db.delete(
_tabla,
where: '$_colUuid = ?',
whereArgs: [uuid],
);
await db.delete('favoritos', where: 'uuid = ?', whereArgs: [uuid]);
}
/// Devuelve `true` si la emisora con [uuid] está en la lista de favoritos.
/// Devuelve true si la emisora con [uuid] está en favoritos.
Future<bool> esFavorito(String uuid) async {
final db = await _database;
final resultado = await db.query(
_tabla,
columns: [_colId],
where: '$_colUuid = ?',
whereArgs: [uuid],
limit: 1,
final count = Sqflite.firstIntValue(
await db.rawQuery(
'SELECT COUNT(*) FROM favoritos WHERE uuid = ?',
[uuid],
),
);
return resultado.isNotEmpty;
return (count ?? 0) > 0;
}
/// Actualiza el [orden] de la emisora con [uuid] al valor [nuevoOrden].
///
/// Si la emisora no existe, no hace nada (idempotente).
/// Devuelve el número de filas actualizadas (0 ó 1).
///
/// Nota: este método actualiza solo la columna `orden` de la emisora
/// indicada. Si necesitas reordenar toda la lista de una vez (drag & drop),
/// construye una lista ordenada y llama a [reordenarLista].
Future<int> reordenar(String uuid, int nuevoOrden) async {
/// 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;
return db.update(
_tabla,
{_colOrden: nuevoOrden},
where: '$_colUuid = ?',
await db.update(
'favoritos',
{'orden': nuevoOrden},
where: 'uuid = ?',
whereArgs: [uuid],
);
}
/// Reordena la lista completa de favoritos en una sola transacción.
///
/// Recibe una lista ordenada de UUIDs y asigna el orden 0, 1, 2... en
/// el mismo orden. Ideal para operaciones de drag & drop.
Future<void> reordenarLista(List<String> uuidsOrdenados) async {
final db = await _database;
await db.transaction((txn) async {
for (var i = 0; i < uuidsOrdenados.length; i++) {
await txn.update(
_tabla,
{_colOrden: i},
where: '$_colUuid = ?',
whereArgs: [uuidsOrdenados[i]],
);
}
});
}
/// Devuelve el número total de emisoras favoritas.
Future<int> contarFavoritos() async {
final db = await _database;
final resultado = await db.rawQuery(
'SELECT COUNT(*) AS total FROM $_tabla',
);
return resultado.first['total'] as int? ?? 0;
}
// ─── Helpers privados ─────────────────────────────────────────────────────
/// Devuelve el valor máximo actual de [_colOrden], o 0 si la tabla está vacía.
Future<int> _maxOrden(Database db) async {
final resultado = await db.rawQuery(
'SELECT MAX($_colOrden) AS max_orden FROM $_tabla',
);
return resultado.first['max_orden'] as int? ?? 0;
}
}

View File

@@ -0,0 +1,144 @@
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();
final uri = _uri(servidor, path, 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

@@ -0,0 +1,97 @@
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

@@ -0,0 +1,114 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../estado/estado_radio.dart';
import '../servicios/servicio_audio.dart';
/// Barra inferior persistente con controles básicos de reproducción.
/// Se muestra siempre que haya una emisora cargada.
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 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: [
// Logo emisora
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Container(
width: 40,
height: 40,
color: theme.colorScheme.primaryContainer,
child: const Icon(Icons.radio, size: 22),
),
),
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: estado.togglePlay,
tooltip: s == EstadoReproduccion.reproduciendo ? 'Pausar' : 'Reproducir',
);
},
),
],
),
),
),
);
}
String _labelEstado(EstadoReproduccion estado) {
switch (estado) {
case EstadoReproduccion.cargando:
return 'Conectando...';
case EstadoReproduccion.reproduciendo:
return 'En directo ●';
case EstadoReproduccion.pausado:
return 'Pausado';
case EstadoReproduccion.error:
return 'Error de conexión';
case EstadoReproduccion.detenido:
return 'Detenido';
}
}
}

View File

@@ -0,0 +1,135 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:shimmer/shimmer.dart';
import '../modelos/emisora.dart';
/// Tarjeta compacta para mostrar una emisora en listas y grids.
class TarjetaEmisora extends StatelessWidget {
final Emisora emisora;
final VoidCallback? onTap;
final bool esCompacta;
const TarjetaEmisora({
super.key,
required this.emisora,
this.onTap,
this.esCompacta = false,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: esCompacta ? _buildCompacta(theme) : _buildCompleta(theme),
),
);
}
Widget _buildCompleta(ThemeData theme) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 1,
child: _logo(theme, 60),
),
Padding(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
emisora.nombre,
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (emisora.pais != null)
Text(
emisora.pais!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
);
}
Widget _buildCompacta(ThemeData theme) {
return ListTile(
leading: SizedBox(width: 48, height: 48, child: _logo(theme, 24)),
title: Text(
emisora.nombre,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
[emisora.pais, emisora.idioma].where((s) => s != null).join(' · '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
);
}
Widget _logo(ThemeData theme, double iconSize) {
if (emisora.favicon != null && emisora.favicon!.isNotEmpty) {
return CachedNetworkImage(
imageUrl: 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),
],
),
);
}
}