Compare commits
2 Commits
2fe1d60e23
...
feature/bd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f818cbc43a | ||
|
|
a3444d7c49 |
@@ -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 }}
|
||||
@@ -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.*
|
||||
18
CHANGELOG.md
18
CHANGELOG.md
@@ -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) |
|
||||
|
||||
39
README.md
39
README.md
@@ -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
97
lib/modelos/emisora.dart
Normal 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;
|
||||
}
|
||||
251
lib/servicios/servicio_favoritos.dart
Normal file
251
lib/servicios/servicio_favoritos.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user