2 Commits

Author SHA1 Message Date
agent-dev-bd
f818cbc43a feat(bd): implementar ServicioFavoritos con sqflite 2026-04-04 16:28:19 +02:00
agent-dev-bd
a3444d7c49 feat(bd): añadir modelo Emisora 2026-04-04 16:28:18 +02:00
6 changed files with 358 additions and 256 deletions

View File

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

View File

@@ -1,143 +0,0 @@
# 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.*

View File

@@ -1,18 +0,0 @@
# 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** — 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

97
lib/modelos/emisora.dart Normal file
View File

@@ -0,0 +1,97 @@
/// 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.
class Emisora {
final int? id;
final String uuid;
final String nombre;
final String url;
final String? favicon;
final String? pais;
final String? idioma;
final String? tags;
final int orden;
const Emisora({
this.id,
required this.uuid,
required this.nombre,
required this.url,
this.favicon,
this.pais,
this.idioma,
this.tags,
this.orden = 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?,
idioma: map['idioma'] as String?,
tags: map['tags'] as String?,
orden: map['orden'] as int? ?? 0,
);
}
/// Serializa la emisora para inserción/actualización en SQLite.
/// No incluye [id] — lo gestiona la BD.
Map<String, dynamic> toMap() {
return {
'uuid': uuid,
'nombre': nombre,
'url': url,
'favicon': favicon,
'pais': pais,
'idioma': idioma,
'tags': tags,
'orden': orden,
};
}
/// Devuelve una copia con los campos indicados modificados.
Emisora copyWith({
int? id,
String? uuid,
String? nombre,
String? url,
String? favicon,
String? pais,
String? idioma,
String? tags,
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,
idioma: idioma ?? this.idioma,
tags: tags ?? this.tags,
orden: orden ?? this.orden,
);
}
@override
String toString() =>
'Emisora(id: $id, uuid: $uuid, nombre: $nombre, orden: $orden)';
@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

@@ -0,0 +1,251 @@
import 'package:path/path.dart';
import 'package:sqflite/sqflite.dart';
import '../modelos/emisora.dart';
/// Servicio de persistencia de emisoras favoritas con SQLite (sqflite).
///
/// ### 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');
/// ```
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();
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();
return _db!;
}
Future<Database> _abrirBd() async {
final ruta = join(await getDatabasesPath(), _dbNombre);
return openDatabase(
ruta,
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
)
''');
// Í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;
}
}
}
// ─── API pública ──────────────────────────────────────────────────────────
/// Devuelve todas las emisoras favoritas ordenadas por [_colOrden] ASC.
///
/// Nunca devuelve null; devuelve lista vacía si no hay favoritos.
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();
}
/// 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 {
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(),
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 {
final db = await _database;
return db.delete(
_tabla,
where: '$_colUuid = ?',
whereArgs: [uuid],
);
}
/// Devuelve `true` si la emisora con [uuid] está en la lista de 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,
);
return resultado.isNotEmpty;
}
/// 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 {
final db = await _database;
return db.update(
_tabla,
{_colOrden: nuevoOrden},
where: '$_colUuid = ?',
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;
}
}