319 lines
9.2 KiB
Dart
319 lines
9.2 KiB
Dart
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 3 añade agrupaciones de favoritos.
|
|
class ServicioFavoritos {
|
|
static const _dbName = 'pluriwave.db';
|
|
static const _dbVersion = 3;
|
|
|
|
ServicioFavoritos({
|
|
DatabaseFactory? databaseFactory,
|
|
Future<String> Function()? databasePathProvider,
|
|
String? databaseName,
|
|
}) : _databaseFactory = databaseFactory,
|
|
_databasePathProvider = databasePathProvider ?? getDatabasesPath,
|
|
_databaseName = databaseName ?? _dbName;
|
|
|
|
final DatabaseFactory? _databaseFactory;
|
|
final Future<String> Function() _databasePathProvider;
|
|
final String _databaseName;
|
|
|
|
Database? _db;
|
|
|
|
Future<Database> get _database async {
|
|
_db ??= await _initDb();
|
|
return _db!;
|
|
}
|
|
|
|
Future<void> cerrar() async {
|
|
await _db?.close();
|
|
_db = null;
|
|
}
|
|
|
|
Future<Database> _initDb() async {
|
|
final dbPath = await _databasePathProvider();
|
|
await Directory(dbPath).create(recursive: true);
|
|
final path = join(dbPath, _databaseName);
|
|
final factory = _databaseFactory;
|
|
if (factory != null) {
|
|
return factory.openDatabase(
|
|
path,
|
|
options: OpenDatabaseOptions(
|
|
version: _dbVersion,
|
|
onCreate: _onCreate,
|
|
onUpgrade: _onUpgrade,
|
|
onOpen: _asegurarEsquema,
|
|
),
|
|
);
|
|
}
|
|
return openDatabase(
|
|
path,
|
|
version: _dbVersion,
|
|
onCreate: _onCreate,
|
|
onUpgrade: _onUpgrade,
|
|
onOpen: _asegurarEsquema,
|
|
);
|
|
}
|
|
|
|
Future<void> _onCreate(Database db, int version) async {
|
|
await db.execute('''
|
|
CREATE TABLE favoritos (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
uuid TEXT NOT NULL UNIQUE,
|
|
nombre TEXT NOT NULL,
|
|
url TEXT NOT NULL,
|
|
favicon TEXT,
|
|
pais TEXT,
|
|
codigo_pais TEXT,
|
|
idioma TEXT,
|
|
tags TEXT,
|
|
codec TEXT,
|
|
bitrate INTEGER,
|
|
votes INTEGER NOT NULL DEFAULT 0,
|
|
clickcount 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 {
|
|
await _asegurarEsquema(db);
|
|
}
|
|
|
|
Future<void> _asegurarEsquema(Database db) async {
|
|
final tablas = await db.rawQuery(
|
|
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'favoritos'",
|
|
);
|
|
if (tablas.isEmpty) {
|
|
await _onCreate(db, _dbVersion);
|
|
return;
|
|
}
|
|
|
|
final columnas = await _columnas(db, 'favoritos');
|
|
Future<void> addColumn(String nombre, String sql) async {
|
|
if (!columnas.contains(nombre)) {
|
|
await db.execute('ALTER TABLE favoritos ADD COLUMN $nombre $sql');
|
|
columnas.add(nombre);
|
|
}
|
|
}
|
|
|
|
// 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');
|
|
await addColumn('idioma', 'TEXT');
|
|
await addColumn('tags', 'TEXT');
|
|
await addColumn('codec', 'TEXT');
|
|
await addColumn('bitrate', 'INTEGER');
|
|
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 {
|
|
final info = await db.rawQuery('PRAGMA table_info($tabla)');
|
|
return info.map((row) => row['name'] as String).toSet();
|
|
}
|
|
|
|
Future<List<Emisora>> obtenerTodos() async {
|
|
final db = await _database;
|
|
final rows = await db.query('favoritos', orderBy: 'orden ASC');
|
|
return rows.map(Emisora.fromMap).toList();
|
|
}
|
|
|
|
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;
|
|
final maxOrden = Sqflite.firstIntValue(
|
|
await db.rawQuery('SELECT MAX(orden) FROM favoritos'),
|
|
) ??
|
|
-1;
|
|
final nuevaEmisora = emisora.copyWith(
|
|
orden: maxOrden + 1,
|
|
grupoFavoritosId: GrupoFavoritos.sinAsignarId,
|
|
);
|
|
await db.insert(
|
|
'favoritos',
|
|
nuevaEmisora.toMap(),
|
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
|
);
|
|
}
|
|
|
|
Future<void> eliminar(String uuid) async {
|
|
final db = await _database;
|
|
await db.delete('favoritos', where: 'uuid = ?', whereArgs: [uuid]);
|
|
}
|
|
|
|
Future<bool> esFavorito(String uuid) async {
|
|
final db = await _database;
|
|
final count = Sqflite.firstIntValue(
|
|
await db.rawQuery(
|
|
'SELECT COUNT(*) FROM favoritos WHERE uuid = ?',
|
|
[uuid],
|
|
),
|
|
);
|
|
return (count ?? 0) > 0;
|
|
}
|
|
|
|
Future<bool> toggleFavorito(Emisora emisora) async {
|
|
if (await esFavorito(emisora.uuid)) {
|
|
await eliminar(emisora.uuid);
|
|
return false;
|
|
} else {
|
|
await agregar(emisora);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
Future<void> reordenar(String uuid, int nuevoOrden) async {
|
|
final db = await _database;
|
|
await db.transaction((txn) async {
|
|
final rows = await txn.query(
|
|
'favoritos',
|
|
columns: ['uuid'],
|
|
orderBy: 'orden ASC, id ASC',
|
|
);
|
|
final uuids = rows.map((r) => r['uuid'] as String).toList();
|
|
final oldIndex = uuids.indexOf(uuid);
|
|
if (oldIndex == -1 || uuids.isEmpty) return;
|
|
|
|
final targetIndex = nuevoOrden.clamp(0, uuids.length - 1);
|
|
final moved = uuids.removeAt(oldIndex);
|
|
uuids.insert(targetIndex, moved);
|
|
|
|
for (var i = 0; i < uuids.length; i++) {
|
|
await txn.update(
|
|
'favoritos',
|
|
{'orden': i},
|
|
where: 'uuid = ?',
|
|
whereArgs: [uuids[i]],
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|