Files
pluriwave/lib/servicios/servicio_favoritos.dart
T
FreeTLab 5f35ab7d6a
Build & Deploy PluriWave / Análisis de código (push) Successful in 36s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 3m27s
feat: restaurar los grupos de favoritos al importar y recordar la lista del coche
Dos fallos reportados desde el uso real en el coche.

Los grupos de favoritos no volvian al importar

La exportacion nunca estuvo rota: los grupos viajaban desde siempre y la
asignacion de cada emisora va dentro de cada favorito como grupo_id. El fallo
estaba solo al importar. La restauracion reutilizaba ServicioFavoritos.agregar,
que fuerza "sin asignar" a proposito, porque una emisora recien marcada como
favorita no tiene grupo. Correcto para esa ruta, destructivo como primitiva de
restauracion: los grupos volvian vacios y todo aterrizaba en Sin asignar. Se
separa la ruta de restaurar, que respeta el grupo_id del backup y su orden.

Ningun test lo detectaba porque el doble de pruebas era infiel: el fake
conservaba el grupo que la implementacion real destruia, y no tenia
restaurarGrupo, asi que esa ruta estaba sin cubrir. Los tests existentes lo
esquivaban pasando siempre una lista de grupos vacia. Se corrige el doble.

No hace falta subir la version del formato: el envoltorio ya llevaba todo.

El coche perdia la lista al reconectar

El contexto de reproduccion vivia solo en memoria, y el motor que arranca
Android Auto es un proceso nuevo sin interfaz ni EstadoRadio, asi que al
reconectar se reproducia la ultima emisora sin saber a que lista pertenecia y
siguiente/anterior no hacian nada hasta entrar a favoritos a mano. Ahora se
persiste el TIPO de contexto y, cuando aplica, el id del grupo, y se resuelve
contra las listas vivas en cada salto: si el grupo cambia de contenido entre
sesiones, el coche ve lo actual y no una foto vieja.

Cadena de repliegue, decidida por el propietario:
  grupo vivo con la emisora dentro  -> se recorre el grupo
  grupo vivo sin la emisora         -> se permanece en el grupo, primera
  grupo vivo pero vacio             -> se ensancha a favoritos
  grupo borrado                     -> favoritos
  sin favoritos                     -> comportamiento actual

Honrar un grupo de una sola emisora exigia levantar dos barreras, no una:
el resolutor y el propio _saltarEmisora, que se negaba a nombrar un grupo con
menos de dos miembros. emisoraVecina queda intacta: su contrato de no saltar a
ciegas es deliberado y se usa desde mas sitios, asi que el caso de "la emisora
se salio del grupo" se trata en el flujo del salto.

La puerta de entitlement no cambia: un conductor sin premium sigue recorriendo
solo el conjunto gratuito, y un contexto congelado mientras pagaba se descarta
en vez de recorrerse.

Suite completa: 1501 pasan, 2 omitidos. flutter analyze mantiene los 5 avisos
preexistentes.
2026-09-02 22:56:10 +02:00

371 lines
11 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],
);
}
/// Restaura un grupo tal como estaba en el dispositivo de origen.
/// Hace un upsert preservando id, nombre y orden originales.
/// Usado exclusivamente por importarConfig para garantizar portabilidad completa.
Future<void> restaurarGrupo(GrupoFavoritos grupo) async {
if (grupo.esSinAsignar) return;
final db = await _database;
await db.insert(
'grupos_favoritos',
grupo.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
/// Restaura un favorito tal como estaba en el dispositivo de origen,
/// preservando su `orden` y su `grupo_id`.
/// Usado exclusivamente por importarConfig, igual que [restaurarGrupo].
///
/// Existe porque [agregar] NO sirve como primitiva de restauración: es la
/// primitiva de «marcar como favorita» y fuerza `sin_asignar` más un
/// `orden` al final de la lista, cosa correcta para una emisora recién
/// marcada (que de verdad no pertenece a ningún grupo) y destructiva para
/// una copia de seguridad, que trae ambos campos. Reusarla era la causa de
/// que los grupos volvieran vacíos tras restaurar.
///
/// El grupo se valida igual que en [asignarGrupo]: un `grupo_id` que no
/// existe en `grupos_favoritos` cae a [GrupoFavoritos.sinAsignarId], de modo
/// que una copia editada a mano o restaurada a medias no puede dejar
/// emisoras apuntando a un grupo inexistente. `importarConfig` restaura los
/// grupos ANTES de este bucle, así que en el camino normal siempre existen.
Future<void> restaurarFavorito(Emisora emisora) async {
final db = await _database;
final existe =
Sqflite.firstIntValue(
await db.rawQuery(
'SELECT COUNT(*) FROM grupos_favoritos WHERE id = ?',
[emisora.grupoFavoritosId],
),
) ??
0;
final restaurada =
existe > 0
? emisora
: emisora.copyWith(grupoFavoritosId: GrupoFavoritos.sinAsignarId);
await db.insert(
'favoritos',
restaurada.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
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;
}
}