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
+3 -1
View File
@@ -43,5 +43,7 @@
- [ ] Permitir crear listas de favoritos con nombre corto configurable por el usuario desde Ajustes. - [ ] Permitir crear listas de favoritos con nombre corto configurable por el usuario desde Ajustes.
- [ ] Mantener siempre un grupo interno por defecto traducible llamado "Sin asignar", no editable y no borrable. - [ ] Mantener siempre un grupo interno por defecto traducible llamado "Sin asignar", no editable y no borrable.
- [ ] Gestionar desde la vista Favoritos qu? emisoras pertenecen a cada agrupaci?n/lista. - [ ] Gestionar desde la vista Favoritos qu? emisoras pertenecen a cada agrupaci?n/lista.
- [ ] Dise?ar migraci?n SQLite para asociar favoritos existentes al grupo "Sin asignar" sin perder datos. - [x] Dise?ar migraci?n SQLite base para asociar favoritos existentes al grupo "Sin asignar" sin perder datos.
- [ ] Completar UI en Ajustes para crear, editar y borrar listas de favoritos.
- [ ] Completar UI en Favoritos para mover emisoras entre listas.
+29
View File
@@ -10,6 +10,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import '../modelos/emisora.dart'; import '../modelos/emisora.dart';
import '../modelos/grupo_favoritos.dart';
import '../modelos/preset_ecualizador.dart'; import '../modelos/preset_ecualizador.dart';
import '../servicios/servicio_audio.dart'; import '../servicios/servicio_audio.dart';
import '../servicios/servicio_ecualizador.dart'; import '../servicios/servicio_ecualizador.dart';
@@ -68,6 +69,7 @@ class EstadoRadio extends ChangeNotifier {
List<Emisora> _resultadosBusqueda = []; List<Emisora> _resultadosBusqueda = [];
List<Emisora> _emisorasCercanas = []; List<Emisora> _emisorasCercanas = [];
List<Emisora> _listaFavoritos = []; List<Emisora> _listaFavoritos = [];
List<GrupoFavoritos> _gruposFavoritos = [];
List<Emisora> _emisorasCustom = []; List<Emisora> _emisorasCustom = [];
// Presets EQ guardados por uuid de emisora. // Presets EQ guardados por uuid de emisora.
@@ -114,6 +116,7 @@ class EstadoRadio extends ChangeNotifier {
List<Emisora> get resultadosBusqueda => _ordenarEmisoras(_resultadosBusqueda); List<Emisora> get resultadosBusqueda => _ordenarEmisoras(_resultadosBusqueda);
List<Emisora> get emisorasCercanas => _ordenarEmisoras(_emisorasCercanas); List<Emisora> get emisorasCercanas => _ordenarEmisoras(_emisorasCercanas);
List<Emisora> get listaFavoritos => _ordenarEmisoras(_listaFavoritos); List<Emisora> get listaFavoritos => _ordenarEmisoras(_listaFavoritos);
List<GrupoFavoritos> get gruposFavoritos => List.unmodifiable(_gruposFavoritos);
List<Emisora> get emisorasCustom => _ordenarEmisoras(_emisorasCustom); List<Emisora> get emisorasCustom => _ordenarEmisoras(_emisorasCustom);
bool get cargandoPopulares => _cargandoPopulares; bool get cargandoPopulares => _cargandoPopulares;
bool get cargandoBusqueda => _cargandoBusqueda; bool get cargandoBusqueda => _cargandoBusqueda;
@@ -202,6 +205,7 @@ class EstadoRadio extends ChangeNotifier {
await Future.wait([ await Future.wait([
cargarPopulares(), cargarPopulares(),
cargarFavoritos(), cargarFavoritos(),
cargarGruposFavoritos(),
_cargarEmisorasCustom(), _cargarEmisorasCustom(),
]); ]);
await _normalizarEmisoraPreferida(); await _normalizarEmisoraPreferida();
@@ -277,6 +281,31 @@ class EstadoRadio extends ChangeNotifier {
notifyListeners(); 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 { Future<void> cambiarEmisoraPreferida(Emisora? emisora) async {
_emisoraPreferidaUuid = emisora?.uuid; _emisoraPreferidaUuid = emisora?.uuid;
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
+6
View File
@@ -18,6 +18,7 @@ class Emisora {
final int votes; final int votes;
final int clickcount; final int clickcount;
final int orden; final int orden;
final String grupoFavoritosId;
const Emisora({ const Emisora({
this.id, this.id,
@@ -34,6 +35,7 @@ class Emisora {
this.votes = 0, this.votes = 0,
this.clickcount = 0, this.clickcount = 0,
this.orden = 0, this.orden = 0,
this.grupoFavoritosId = 'sin_asignar',
}); });
/// Construye una [Emisora] desde la respuesta JSON de Radio Browser API. /// Construye una [Emisora] desde la respuesta JSON de Radio Browser API.
@@ -71,6 +73,7 @@ class Emisora {
votes: map['votes'] as int? ?? 0, votes: map['votes'] as int? ?? 0,
clickcount: map['clickcount'] as int? ?? 0, clickcount: map['clickcount'] as int? ?? 0,
orden: map['orden'] 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, 'votes': votes,
'clickcount': clickcount, 'clickcount': clickcount,
'orden': orden, 'orden': orden,
'grupo_id': grupoFavoritosId,
}; };
} }
@@ -108,6 +112,7 @@ class Emisora {
int? votes, int? votes,
int? clickcount, int? clickcount,
int? orden, int? orden,
String? grupoFavoritosId,
}) { }) {
return Emisora( return Emisora(
id: id ?? this.id, id: id ?? this.id,
@@ -124,6 +129,7 @@ class Emisora {
votes: votes ?? this.votes, votes: votes ?? this.votes,
clickcount: clickcount ?? this.clickcount, clickcount: clickcount ?? this.clickcount,
orden: orden ?? this.orden, 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:path/path.dart';
import 'package:sqflite/sqflite.dart'; import 'package:sqflite/sqflite.dart';
import '../modelos/emisora.dart'; import '../modelos/emisora.dart';
import '../modelos/grupo_favoritos.dart';
/// Servicio de persistencia de emisoras favoritas con SQLite. /// Servicio de persistencia de emisoras favoritas con SQLite.
/// ///
/// - Inicialización lazy: la BD se abre en el primer acceso. /// - Inicializaci?n lazy: la BD se abre en el primer acceso.
/// - Migration-ready: versión 2 añade campos de la Radio Browser API. /// - Migration-ready: versi?n 3 a?ade agrupaciones de favoritos.
class ServicioFavoritos { class ServicioFavoritos {
static const _dbName = 'pluriwave.db'; static const _dbName = 'pluriwave.db';
static const _dbVersion = 2; static const _dbVersion = 3;
Database? _db; Database? _db;
@@ -48,9 +50,12 @@ class ServicioFavoritos {
bitrate INTEGER, bitrate INTEGER,
votes INTEGER NOT NULL DEFAULT 0, votes INTEGER NOT NULL DEFAULT 0,
clickcount 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 { 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 // Migraci?n defensiva: algunas instalaciones antiguas pueden venir de
// esquemas intermedios. No asumimos qué columna existe: la verificamos. // esquemas intermedios. No asumimos qu? columna existe: la verificamos.
await addColumn('favicon', 'TEXT'); await addColumn('favicon', 'TEXT');
await addColumn('pais', 'TEXT'); await addColumn('pais', 'TEXT');
await addColumn('codigo_pais', 'TEXT'); await addColumn('codigo_pais', 'TEXT');
@@ -86,6 +91,36 @@ class ServicioFavoritos {
await addColumn('votes', 'INTEGER NOT NULL DEFAULT 0'); await addColumn('votes', 'INTEGER NOT NULL DEFAULT 0');
await addColumn('clickcount', 'INTEGER NOT NULL DEFAULT 0'); await addColumn('clickcount', 'INTEGER NOT NULL DEFAULT 0');
await addColumn('orden', '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 { Future<Set<String>> _columnas(Database db, String tabla) async {
@@ -93,22 +128,94 @@ class ServicioFavoritos {
return info.map((row) => row['name'] as String).toSet(); return info.map((row) => row['name'] as String).toSet();
} }
/// Devuelve todas las emisoras favoritas ordenadas por [orden].
Future<List<Emisora>> obtenerTodos() async { Future<List<Emisora>> obtenerTodos() async {
final db = await _database; final db = await _database;
final rows = await db.query('favoritos', orderBy: 'orden ASC'); final rows = await db.query('favoritos', orderBy: 'orden ASC');
return rows.map(Emisora.fromMap).toList(); 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 { Future<void> agregar(Emisora emisora) async {
final db = await _database; final db = await _database;
// Calcular el siguiente orden
final maxOrden = Sqflite.firstIntValue( final maxOrden = Sqflite.firstIntValue(
await db.rawQuery('SELECT MAX(orden) FROM favoritos'), await db.rawQuery('SELECT MAX(orden) FROM favoritos'),
) ?? ) ??
-1; -1;
final nuevaEmisora = emisora.copyWith(orden: maxOrden + 1); final nuevaEmisora = emisora.copyWith(
orden: maxOrden + 1,
grupoFavoritosId: GrupoFavoritos.sinAsignarId,
);
await db.insert( await db.insert(
'favoritos', 'favoritos',
nuevaEmisora.toMap(), nuevaEmisora.toMap(),
@@ -116,13 +223,11 @@ class ServicioFavoritos {
); );
} }
/// Elimina una emisora de favoritos por [uuid].
Future<void> eliminar(String uuid) async { Future<void> eliminar(String uuid) async {
final db = await _database; final db = await _database;
await db.delete('favoritos', where: 'uuid = ?', whereArgs: [uuid]); await db.delete('favoritos', where: 'uuid = ?', whereArgs: [uuid]);
} }
/// Devuelve true si la emisora con [uuid] está en favoritos.
Future<bool> esFavorito(String uuid) async { Future<bool> esFavorito(String uuid) async {
final db = await _database; final db = await _database;
final count = Sqflite.firstIntValue( final count = Sqflite.firstIntValue(
@@ -134,7 +239,6 @@ class ServicioFavoritos {
return (count ?? 0) > 0; return (count ?? 0) > 0;
} }
/// Alterna el estado de favorito de una emisora.
Future<bool> toggleFavorito(Emisora emisora) async { Future<bool> toggleFavorito(Emisora emisora) async {
if (await esFavorito(emisora.uuid)) { if (await esFavorito(emisora.uuid)) {
await eliminar(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 { Future<void> reordenar(String uuid, int nuevoOrden) async {
final db = await _database; final db = await _database;
await db.transaction((txn) async { 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;
}
} }
+32
View File
@@ -367,6 +367,38 @@ void main() {
await estado.toggleFavorito(emisora); await estado.toggleFavorito(emisora);
expect(estado.listaFavoritos.any((e) => e.uuid == emisora.uuid), isFalse); expect(estado.listaFavoritos.any((e) => e.uuid == emisora.uuid), isFalse);
}); });
test('crea y asigna grupos de favoritos sin tocar Sin asignar', () async {
final favoritos = FakeServicioFavoritos();
final emisora = emisoraDemo(uuid: 'fav-group', nombre: 'Grupo');
await favoritos.agregar(emisora);
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: favoritos,
radio: FakeServicioRadio(populares: [emisora]),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
await estado.inicializar();
expect(estado.gruposFavoritos.first.protegido, isTrue);
await estado.crearGrupoFavoritos('Noticias');
final grupo = estado.gruposFavoritos.last;
await estado.asignarGrupoFavorito(emisora.uuid, grupo.id);
expect(
estado.listaFavoritos.first.grupoFavoritosId,
grupo.id,
);
await estado.eliminarGrupoFavoritos(grupo.id);
expect(
estado.listaFavoritos.first.grupoFavoritosId,
'sin_asignar',
);
});
}); });
} }
+54
View File
@@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:pluriwave/modelos/emisora.dart'; import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/modelos/grupo_favoritos.dart';
import 'package:pluriwave/modelos/preset_ecualizador.dart'; import 'package:pluriwave/modelos/preset_ecualizador.dart';
import 'package:pluriwave/servicios/servicio_audio.dart'; import 'package:pluriwave/servicios/servicio_audio.dart';
import 'package:pluriwave/servicios/servicio_ecualizador.dart'; import 'package:pluriwave/servicios/servicio_ecualizador.dart';
@@ -71,6 +72,14 @@ class FakeServicioAudio extends ServicioAudio {
class FakeServicioFavoritos extends ServicioFavoritos { class FakeServicioFavoritos extends ServicioFavoritos {
final List<Emisora> _favoritos = []; final List<Emisora> _favoritos = [];
final List<GrupoFavoritos> _grupos = [
const GrupoFavoritos(
id: GrupoFavoritos.sinAsignarId,
nombre: 'Sin asignar',
orden: 0,
protegido: true,
),
];
int toggleCalls = 0; int toggleCalls = 0;
@override @override
@@ -106,6 +115,51 @@ class FakeServicioFavoritos extends ServicioFavoritos {
return true; return true;
} }
@override
Future<List<GrupoFavoritos>> obtenerGrupos() async => List.unmodifiable(_grupos);
@override
Future<GrupoFavoritos> crearGrupo(String nombre) async {
final grupo = GrupoFavoritos(
id: 'grupo_${_grupos.length}',
nombre: nombre.trim(),
orden: _grupos.length,
);
_grupos.add(grupo);
return grupo;
}
@override
Future<void> renombrarGrupo(String id, String nombre) async {
final index = _grupos.indexWhere((g) => g.id == id && !g.protegido);
if (index == -1) return;
_grupos[index] = _grupos[index].copyWith(nombre: nombre.trim());
}
@override
Future<void> eliminarGrupo(String id) async {
if (id == GrupoFavoritos.sinAsignarId) return;
_grupos.removeWhere((g) => g.id == id && !g.protegido);
for (var i = 0; i < _favoritos.length; i++) {
if (_favoritos[i].grupoFavoritosId == id) {
_favoritos[i] = _favoritos[i].copyWith(
grupoFavoritosId: GrupoFavoritos.sinAsignarId,
);
}
}
}
@override
Future<void> asignarGrupo(String uuid, String grupoId) async {
final destino = _grupos.any((g) => g.id == grupoId)
? grupoId
: GrupoFavoritos.sinAsignarId;
final index = _favoritos.indexWhere((e) => e.uuid == uuid);
if (index != -1) {
_favoritos[index] = _favoritos[index].copyWith(grupoFavoritosId: destino);
}
}
@override @override
Future<void> reordenar(String uuid, int nuevoOrden) async { Future<void> reordenar(String uuid, int nuevoOrden) async {
final oldIndex = _favoritos.indexWhere((e) => e.uuid == uuid); final oldIndex = _favoritos.indexWhere((e) => e.uuid == uuid);