From f818cbc43a9fcbbf4f1e7f9c69a1884cc17b16f2 Mon Sep 17 00:00:00 2001 From: agent-dev-bd Date: Sat, 4 Apr 2026 16:28:19 +0200 Subject: [PATCH] feat(bd): implementar ServicioFavoritos con sqflite --- lib/servicios/servicio_favoritos.dart | 251 ++++++++++++++++++++++++++ 1 file changed, 251 insertions(+) create mode 100644 lib/servicios/servicio_favoritos.dart diff --git a/lib/servicios/servicio_favoritos.dart b/lib/servicios/servicio_favoritos.dart new file mode 100644 index 0000000..c40470e --- /dev/null +++ b/lib/servicios/servicio_favoritos.dart @@ -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 get _database async { + _db ??= await _abrirBd(); + return _db!; + } + + Future _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 _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 _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> 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 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 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 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 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 reordenarLista(List 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 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 _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; + } +}