diff --git a/TODO.md b/TODO.md index 8c2a993..cb7a479 100644 --- a/TODO.md +++ b/TODO.md @@ -43,5 +43,7 @@ - [ ] 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. - [ ] 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. diff --git a/lib/estado/estado_radio.dart b/lib/estado/estado_radio.dart index 6570f56..b2a6fbf 100644 --- a/lib/estado/estado_radio.dart +++ b/lib/estado/estado_radio.dart @@ -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 _resultadosBusqueda = []; List _emisorasCercanas = []; List _listaFavoritos = []; + List _gruposFavoritos = []; List _emisorasCustom = []; // Presets EQ guardados por uuid de emisora. @@ -114,6 +116,7 @@ class EstadoRadio extends ChangeNotifier { List get resultadosBusqueda => _ordenarEmisoras(_resultadosBusqueda); List get emisorasCercanas => _ordenarEmisoras(_emisorasCercanas); List get listaFavoritos => _ordenarEmisoras(_listaFavoritos); + List get gruposFavoritos => List.unmodifiable(_gruposFavoritos); List 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 cargarGruposFavoritos() async { + _gruposFavoritos = await favoritos.obtenerGrupos(); + notifyListeners(); + } + + Future crearGrupoFavoritos(String nombre) async { + await favoritos.crearGrupo(nombre); + await cargarGruposFavoritos(); + } + + Future renombrarGrupoFavoritos(String id, String nombre) async { + await favoritos.renombrarGrupo(id, nombre); + await cargarGruposFavoritos(); + } + + Future eliminarGrupoFavoritos(String id) async { + await favoritos.eliminarGrupo(id); + await Future.wait([cargarFavoritos(), cargarGruposFavoritos()]); + } + + Future asignarGrupoFavorito(String uuid, String grupoId) async { + await favoritos.asignarGrupo(uuid, grupoId); + await cargarFavoritos(); + } + Future cambiarEmisoraPreferida(Emisora? emisora) async { _emisoraPreferidaUuid = emisora?.uuid; final prefs = await SharedPreferences.getInstance(); diff --git a/lib/modelos/emisora.dart b/lib/modelos/emisora.dart index 2fc72dc..ac61825 100644 --- a/lib/modelos/emisora.dart +++ b/lib/modelos/emisora.dart @@ -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, ); } diff --git a/lib/modelos/grupo_favoritos.dart b/lib/modelos/grupo_favoritos.dart new file mode 100644 index 0000000..be4e828 --- /dev/null +++ b/lib/modelos/grupo_favoritos.dart @@ -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 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 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, + ); + } +} diff --git a/lib/servicios/servicio_favoritos.dart b/lib/servicios/servicio_favoritos.dart index f6a28c9..bfcaf7b 100644 --- a/lib/servicios/servicio_favoritos.dart +++ b/lib/servicios/servicio_favoritos.dart @@ -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 _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 _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 _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> _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> 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> 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 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 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 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 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 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 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 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 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 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; + } } diff --git a/test/estado/estado_radio_test.dart b/test/estado/estado_radio_test.dart index f2dbbfa..c74d278 100644 --- a/test/estado/estado_radio_test.dart +++ b/test/estado/estado_radio_test.dart @@ -367,6 +367,38 @@ void main() { await estado.toggleFavorito(emisora); 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', + ); + }); }); } diff --git a/test/helpers/fakes.dart b/test/helpers/fakes.dart index 39260e3..f2fd685 100644 --- a/test/helpers/fakes.dart +++ b/test/helpers/fakes.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:pluriwave/modelos/emisora.dart'; +import 'package:pluriwave/modelos/grupo_favoritos.dart'; import 'package:pluriwave/modelos/preset_ecualizador.dart'; import 'package:pluriwave/servicios/servicio_audio.dart'; import 'package:pluriwave/servicios/servicio_ecualizador.dart'; @@ -71,6 +72,14 @@ class FakeServicioAudio extends ServicioAudio { class FakeServicioFavoritos extends ServicioFavoritos { final List _favoritos = []; + final List _grupos = [ + const GrupoFavoritos( + id: GrupoFavoritos.sinAsignarId, + nombre: 'Sin asignar', + orden: 0, + protegido: true, + ), + ]; int toggleCalls = 0; @override @@ -106,6 +115,51 @@ class FakeServicioFavoritos extends ServicioFavoritos { return true; } + @override + Future> obtenerGrupos() async => List.unmodifiable(_grupos); + + @override + Future crearGrupo(String nombre) async { + final grupo = GrupoFavoritos( + id: 'grupo_${_grupos.length}', + nombre: nombre.trim(), + orden: _grupos.length, + ); + _grupos.add(grupo); + return grupo; + } + + @override + Future 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 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 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 Future reordenar(String uuid, int nuevoOrden) async { final oldIndex = _favoritos.indexWhere((e) => e.uuid == uuid);