feat(favorites): add group persistence foundation
Build & Deploy Pluriwave / Análisis de código (push) Successful in 25s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m50s

This commit is contained in:
2026-05-22 16:09:52 +02:00
parent c347ce9d8e
commit 9bd973b327
7 changed files with 301 additions and 15 deletions
+29
View File
@@ -10,6 +10,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:url_launcher/url_launcher.dart';
import '../modelos/emisora.dart';
import '../modelos/grupo_favoritos.dart';
import '../modelos/preset_ecualizador.dart';
import '../servicios/servicio_audio.dart';
import '../servicios/servicio_ecualizador.dart';
@@ -68,6 +69,7 @@ class EstadoRadio extends ChangeNotifier {
List<Emisora> _resultadosBusqueda = [];
List<Emisora> _emisorasCercanas = [];
List<Emisora> _listaFavoritos = [];
List<GrupoFavoritos> _gruposFavoritos = [];
List<Emisora> _emisorasCustom = [];
// Presets EQ guardados por uuid de emisora.
@@ -114,6 +116,7 @@ class EstadoRadio extends ChangeNotifier {
List<Emisora> get resultadosBusqueda => _ordenarEmisoras(_resultadosBusqueda);
List<Emisora> get emisorasCercanas => _ordenarEmisoras(_emisorasCercanas);
List<Emisora> get listaFavoritos => _ordenarEmisoras(_listaFavoritos);
List<GrupoFavoritos> get gruposFavoritos => List.unmodifiable(_gruposFavoritos);
List<Emisora> get emisorasCustom => _ordenarEmisoras(_emisorasCustom);
bool get cargandoPopulares => _cargandoPopulares;
bool get cargandoBusqueda => _cargandoBusqueda;
@@ -202,6 +205,7 @@ class EstadoRadio extends ChangeNotifier {
await Future.wait([
cargarPopulares(),
cargarFavoritos(),
cargarGruposFavoritos(),
_cargarEmisorasCustom(),
]);
await _normalizarEmisoraPreferida();
@@ -277,6 +281,31 @@ class EstadoRadio extends ChangeNotifier {
notifyListeners();
}
Future<void> cargarGruposFavoritos() async {
_gruposFavoritos = await favoritos.obtenerGrupos();
notifyListeners();
}
Future<void> crearGrupoFavoritos(String nombre) async {
await favoritos.crearGrupo(nombre);
await cargarGruposFavoritos();
}
Future<void> renombrarGrupoFavoritos(String id, String nombre) async {
await favoritos.renombrarGrupo(id, nombre);
await cargarGruposFavoritos();
}
Future<void> eliminarGrupoFavoritos(String id) async {
await favoritos.eliminarGrupo(id);
await Future.wait([cargarFavoritos(), cargarGruposFavoritos()]);
}
Future<void> asignarGrupoFavorito(String uuid, String grupoId) async {
await favoritos.asignarGrupo(uuid, grupoId);
await cargarFavoritos();
}
Future<void> cambiarEmisoraPreferida(Emisora? emisora) async {
_emisoraPreferidaUuid = emisora?.uuid;
final prefs = await SharedPreferences.getInstance();
+6
View File
@@ -18,6 +18,7 @@ class Emisora {
final int votes;
final int clickcount;
final int orden;
final String grupoFavoritosId;
const Emisora({
this.id,
@@ -34,6 +35,7 @@ class Emisora {
this.votes = 0,
this.clickcount = 0,
this.orden = 0,
this.grupoFavoritosId = 'sin_asignar',
});
/// Construye una [Emisora] desde la respuesta JSON de Radio Browser API.
@@ -71,6 +73,7 @@ class Emisora {
votes: map['votes'] as int? ?? 0,
clickcount: map['clickcount'] as int? ?? 0,
orden: map['orden'] as int? ?? 0,
grupoFavoritosId: map['grupo_id'] as String? ?? 'sin_asignar',
);
}
@@ -90,6 +93,7 @@ class Emisora {
'votes': votes,
'clickcount': clickcount,
'orden': orden,
'grupo_id': grupoFavoritosId,
};
}
@@ -108,6 +112,7 @@ class Emisora {
int? votes,
int? clickcount,
int? orden,
String? grupoFavoritosId,
}) {
return Emisora(
id: id ?? this.id,
@@ -124,6 +129,7 @@ class Emisora {
votes: votes ?? this.votes,
clickcount: clickcount ?? this.clickcount,
orden: orden ?? this.orden,
grupoFavoritosId: grupoFavoritosId ?? this.grupoFavoritosId,
);
}
+49
View File
@@ -0,0 +1,49 @@
class GrupoFavoritos {
const GrupoFavoritos({
required this.id,
required this.nombre,
required this.orden,
this.protegido = false,
});
static const sinAsignarId = 'sin_asignar';
final String id;
final String nombre;
final int orden;
final bool protegido;
bool get esSinAsignar => id == sinAsignarId;
factory GrupoFavoritos.fromMap(Map<String, dynamic> map) {
return GrupoFavoritos(
id: map['id'] as String,
nombre: map['nombre'] as String,
orden: map['orden'] as int? ?? 0,
protegido: (map['protegido'] as int? ?? 0) == 1,
);
}
Map<String, dynamic> toMap() {
return {
'id': id,
'nombre': nombre,
'orden': orden,
'protegido': protegido ? 1 : 0,
};
}
GrupoFavoritos copyWith({
String? id,
String? nombre,
int? orden,
bool? protegido,
}) {
return GrupoFavoritos(
id: id ?? this.id,
nombre: nombre ?? this.nombre,
orden: orden ?? this.orden,
protegido: protegido ?? this.protegido,
);
}
}
+128 -14
View File
@@ -2,15 +2,17 @@ import 'dart:io';
import 'package:path/path.dart';
import 'package:sqflite/sqflite.dart';
import '../modelos/emisora.dart';
import '../modelos/grupo_favoritos.dart';
/// Servicio de persistencia de emisoras favoritas con SQLite.
///
/// - Inicialización lazy: la BD se abre en el primer acceso.
/// - Migration-ready: versión 2 añade campos de la Radio Browser API.
/// - Inicializaci?n lazy: la BD se abre en el primer acceso.
/// - Migration-ready: versi?n 3 a?ade agrupaciones de favoritos.
class ServicioFavoritos {
static const _dbName = 'pluriwave.db';
static const _dbVersion = 2;
static const _dbVersion = 3;
Database? _db;
@@ -48,9 +50,12 @@ class ServicioFavoritos {
bitrate INTEGER,
votes INTEGER NOT NULL DEFAULT 0,
clickcount INTEGER NOT NULL DEFAULT 0,
orden INTEGER NOT NULL DEFAULT 0
orden INTEGER NOT NULL DEFAULT 0,
grupo_id TEXT NOT NULL DEFAULT 'sin_asignar'
)
''');
await _crearTablaGrupos(db);
await _asegurarGrupoSinAsignar(db);
}
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
@@ -74,8 +79,8 @@ class ServicioFavoritos {
}
}
// Migración defensiva: algunas instalaciones antiguas pueden venir de
// esquemas intermedios. No asumimos qué columna existe: la verificamos.
// Migraci?n defensiva: algunas instalaciones antiguas pueden venir de
// esquemas intermedios. No asumimos qu? columna existe: la verificamos.
await addColumn('favicon', 'TEXT');
await addColumn('pais', 'TEXT');
await addColumn('codigo_pais', 'TEXT');
@@ -86,6 +91,36 @@ class ServicioFavoritos {
await addColumn('votes', 'INTEGER NOT NULL DEFAULT 0');
await addColumn('clickcount', 'INTEGER NOT NULL DEFAULT 0');
await addColumn('orden', 'INTEGER NOT NULL DEFAULT 0');
await addColumn(
'grupo_id',
"TEXT NOT NULL DEFAULT '${GrupoFavoritos.sinAsignarId}'",
);
await _crearTablaGrupos(db);
await _asegurarGrupoSinAsignar(db);
}
Future<void> _crearTablaGrupos(Database db) async {
await db.execute('''
CREATE TABLE IF NOT EXISTS grupos_favoritos (
id TEXT PRIMARY KEY,
nombre TEXT NOT NULL,
orden INTEGER NOT NULL DEFAULT 0,
protegido INTEGER NOT NULL DEFAULT 0
)
''');
}
Future<void> _asegurarGrupoSinAsignar(Database db) async {
await db.insert(
'grupos_favoritos',
const GrupoFavoritos(
id: GrupoFavoritos.sinAsignarId,
nombre: 'Sin asignar',
orden: 0,
protegido: true,
).toMap(),
conflictAlgorithm: ConflictAlgorithm.ignore,
);
}
Future<Set<String>> _columnas(Database db, String tabla) async {
@@ -93,22 +128,94 @@ class ServicioFavoritos {
return info.map((row) => row['name'] as String).toSet();
}
/// Devuelve todas las emisoras favoritas ordenadas por [orden].
Future<List<Emisora>> obtenerTodos() async {
final db = await _database;
final rows = await db.query('favoritos', orderBy: 'orden ASC');
return rows.map(Emisora.fromMap).toList();
}
/// Añade una emisora a favoritos. Si ya existe (mismo uuid), la actualiza.
Future<List<GrupoFavoritos>> obtenerGrupos() async {
final db = await _database;
final rows = await db.query(
'grupos_favoritos',
orderBy: 'orden ASC, nombre ASC',
);
return rows.map(GrupoFavoritos.fromMap).toList();
}
Future<GrupoFavoritos> crearGrupo(String nombre) async {
final db = await _database;
final normalizado = _normalizarNombreGrupo(nombre);
final maxOrden = Sqflite.firstIntValue(
await db.rawQuery('SELECT MAX(orden) FROM grupos_favoritos'),
) ??
0;
final grupo = GrupoFavoritos(
id: 'grupo_${DateTime.now().microsecondsSinceEpoch}',
nombre: normalizado,
orden: maxOrden + 1,
);
await db.insert('grupos_favoritos', grupo.toMap());
return grupo;
}
Future<void> renombrarGrupo(String id, String nombre) async {
if (id == GrupoFavoritos.sinAsignarId) return;
final db = await _database;
await db.update(
'grupos_favoritos',
{'nombre': _normalizarNombreGrupo(nombre)},
where: 'id = ? AND protegido = 0',
whereArgs: [id],
);
}
Future<void> eliminarGrupo(String id) async {
if (id == GrupoFavoritos.sinAsignarId) return;
final db = await _database;
await db.transaction((txn) async {
await txn.update(
'favoritos',
{'grupo_id': GrupoFavoritos.sinAsignarId},
where: 'grupo_id = ?',
whereArgs: [id],
);
await txn.delete(
'grupos_favoritos',
where: 'id = ? AND protegido = 0',
whereArgs: [id],
);
});
}
Future<void> asignarGrupo(String uuid, String grupoId) async {
final db = await _database;
final existe = Sqflite.firstIntValue(
await db.rawQuery(
'SELECT COUNT(*) FROM grupos_favoritos WHERE id = ?',
[grupoId],
),
) ??
0;
final destino = existe > 0 ? grupoId : GrupoFavoritos.sinAsignarId;
await db.update(
'favoritos',
{'grupo_id': destino},
where: 'uuid = ?',
whereArgs: [uuid],
);
}
Future<void> agregar(Emisora emisora) async {
final db = await _database;
// Calcular el siguiente orden
final maxOrden = Sqflite.firstIntValue(
await db.rawQuery('SELECT MAX(orden) FROM favoritos'),
) ??
-1;
final nuevaEmisora = emisora.copyWith(orden: maxOrden + 1);
final nuevaEmisora = emisora.copyWith(
orden: maxOrden + 1,
grupoFavoritosId: GrupoFavoritos.sinAsignarId,
);
await db.insert(
'favoritos',
nuevaEmisora.toMap(),
@@ -116,13 +223,11 @@ class ServicioFavoritos {
);
}
/// Elimina una emisora de favoritos por [uuid].
Future<void> eliminar(String uuid) async {
final db = await _database;
await db.delete('favoritos', where: 'uuid = ?', whereArgs: [uuid]);
}
/// Devuelve true si la emisora con [uuid] está en favoritos.
Future<bool> esFavorito(String uuid) async {
final db = await _database;
final count = Sqflite.firstIntValue(
@@ -134,7 +239,6 @@ class ServicioFavoritos {
return (count ?? 0) > 0;
}
/// Alterna el estado de favorito de una emisora.
Future<bool> toggleFavorito(Emisora emisora) async {
if (await esFavorito(emisora.uuid)) {
await eliminar(emisora.uuid);
@@ -145,7 +249,6 @@ class ServicioFavoritos {
}
}
/// Actualiza el orden de un favorito.
Future<void> reordenar(String uuid, int nuevoOrden) async {
final db = await _database;
await db.transaction((txn) async {
@@ -172,4 +275,15 @@ class ServicioFavoritos {
}
});
}
String _normalizarNombreGrupo(String nombre) {
final normalizado = nombre.trim().replaceAll(RegExp(r'\s+'), ' ');
if (normalizado.isEmpty) {
throw ArgumentError('El nombre del grupo no puede estar vac?o');
}
if (normalizado.length > 28) {
throw ArgumentError('El nombre del grupo no puede superar 28 caracteres');
}
return normalizado;
}
}