52855e75c2
- New EstadoGrabacion owns the recording service, subscription, directory/size preferences and open-file actions - New EstadoBusqueda owns search, nearby stations, pagination and the min-bitrate filter - New orden_emisoras.dart with the OrdenEmisoras enum, shared sorter and list identity memoization so context.select comparisons work on derived lists - Large screens (inicio, buscar, favoritos, ajustes, reproductor) consume scoped selects/dedicated notifiers instead of root context.watch<EstadoRadio>, so audio buffer events no longer rebuild whole screens - Remove all 15 TODO(S4b) compat members from EstadoRadio; consumers use the dedicated providers. EstadoRadio drops from ~1121 to 753 lines, keeping playback/stations/favorites orchestration - 8 new tests including a rebuild-scoping probe (110 total green), flutter analyze clean
754 lines
26 KiB
Dart
754 lines
26 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/widgets.dart' show Locale;
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
import '../l10n/display_names.dart';
|
|
import '../l10n/gen/app_localizations.dart';
|
|
import '../modelos/emisora.dart';
|
|
import '../modelos/grupo_favoritos.dart';
|
|
import '../modelos/preset_ecualizador.dart';
|
|
import 'estado_busqueda.dart';
|
|
import 'estado_ecualizador.dart';
|
|
import 'estado_grabacion.dart';
|
|
import 'orden_emisoras.dart';
|
|
import '../servicios/servicio_audio.dart';
|
|
import '../servicios/servicio_ecualizador.dart';
|
|
import '../servicios/servicio_export_import.dart';
|
|
import '../servicios/servicio_favoritos.dart';
|
|
import '../servicios/servicio_grabacion_radio.dart';
|
|
import '../servicios/servicio_radio.dart';
|
|
import '../servicios/servicio_timer.dart';
|
|
|
|
export 'orden_emisoras.dart' show OrdenEmisoras;
|
|
|
|
/// Estado global de la app con ChangeNotifier (Provider).
|
|
///
|
|
/// S4 end-state: playback + stations + favorites orchestration. EQ, recording
|
|
/// and search state live in their own notifiers (EstadoEcualizador,
|
|
/// EstadoGrabacion, EstadoBusqueda) created here during the S4 transition and
|
|
/// exposed app-wide through ListenableProviders in app.dart.
|
|
class EstadoRadio extends ChangeNotifier {
|
|
EstadoRadio({
|
|
ServicioAudio? audio,
|
|
ServicioFavoritos? favoritos,
|
|
ServicioRadio? radio,
|
|
ServicioEcualizador? servicioEcualizador,
|
|
ServicioGrabacionRadio? servicioGrabacion,
|
|
SharedPreferences? prefs,
|
|
Future<File> Function()? resolverArchivoCustom,
|
|
bool iniciarAutomaticamente = true,
|
|
}) : audio = audio ?? ServicioAudio(),
|
|
favoritos = favoritos ?? ServicioFavoritos(),
|
|
radio = radio ?? ServicioRadio(),
|
|
servicioEcualizador =
|
|
servicioEcualizador ?? ServicioEcualizador(prefs: prefs),
|
|
_prefs = prefs,
|
|
_resolverArchivoCustom = resolverArchivoCustom {
|
|
ecualizador = EstadoEcualizador(
|
|
audio: this.audio,
|
|
servicio: this.servicioEcualizador,
|
|
emisoraActualUuid: () => emisoraActual?.uuid,
|
|
);
|
|
grabacion = EstadoGrabacion(
|
|
servicio: servicioGrabacion ?? ServicioGrabacionRadio(prefs: prefs),
|
|
emisoraActual: () => emisoraActual,
|
|
alError: _errorController.add,
|
|
);
|
|
busqueda = EstadoBusqueda(
|
|
radio: this.radio,
|
|
ordenListas: () => _ordenListas,
|
|
textos: () => _textos,
|
|
alError: _errorController.add,
|
|
);
|
|
timer = ServicioTimer(this.audio);
|
|
_escucharErroresReproduccion();
|
|
if (iniciarAutomaticamente) {
|
|
_initFuture = _init();
|
|
}
|
|
}
|
|
|
|
final ServicioAudio audio;
|
|
final ServicioFavoritos favoritos;
|
|
final ServicioRadio radio;
|
|
final ServicioEcualizador servicioEcualizador;
|
|
|
|
/// Domain notifiers extracted from this class (S4). Created and disposed
|
|
/// here (they need EstadoRadio's services and callbacks at construction);
|
|
/// exposed app-wide through ListenableProviders in app.dart.
|
|
late final EstadoEcualizador ecualizador;
|
|
late final EstadoGrabacion grabacion;
|
|
late final EstadoBusqueda busqueda;
|
|
static const ServicioExportImport _exportImport = ServicioExportImport();
|
|
final SharedPreferences? _prefs;
|
|
final Future<File> Function()? _resolverArchivoCustom;
|
|
|
|
/// Single startup instance injected from main() (S3-R4); falls back to
|
|
/// getInstance() only when nothing was injected (tests, legacy callers).
|
|
Future<SharedPreferences> _resolverPrefs() async =>
|
|
_prefs ?? SharedPreferences.getInstance();
|
|
|
|
AppLocalizations get _textos {
|
|
final actual = _l10n;
|
|
if (actual != null) return actual;
|
|
return lookupAppLocalizations(const Locale('es'));
|
|
}
|
|
|
|
void configurarLocalizaciones(AppLocalizations l10n) {
|
|
_l10n = l10n;
|
|
audio.configurarLocalizaciones(l10n);
|
|
grabacion.configurarLocalizaciones(l10n);
|
|
// The alarm bridge gets its localizations through
|
|
// EstadoAlarmas.configurarLocalizaciones (Decision 3.2) — the old
|
|
// static ServicioAlarmasAndroid shim is gone.
|
|
}
|
|
|
|
late final ServicioTimer timer;
|
|
StreamSubscription<EstadoReproduccion>? _suscripcionEstadoAudio;
|
|
Future<void>? _initFuture;
|
|
int _revisionReproduccion = 0;
|
|
Emisora? _emisoraSeleccionada;
|
|
String? _emisoraPreferidaUuid;
|
|
AppLocalizations? _l10n;
|
|
|
|
// Errores de reproducción → SnackBar.
|
|
final _errorController = StreamController<String>.broadcast();
|
|
Stream<String> get errorStream => _errorController.stream;
|
|
|
|
List<Emisora> _populares = [];
|
|
List<Emisora> _tendencias = [];
|
|
List<Emisora> _listaFavoritos = [];
|
|
List<GrupoFavoritos> _gruposFavoritos = [];
|
|
List<Emisora> _emisorasCustom = [];
|
|
|
|
bool _cargandoPopulares = false;
|
|
String? _errorCarga;
|
|
|
|
// Identity-memoized derived lists so `context.select` consumers only
|
|
// rebuild when the underlying data actually changes (S4-R5).
|
|
final _memoPopulares = MemoLista<Emisora>();
|
|
final _memoTendencias = MemoLista<Emisora>();
|
|
final _memoFavoritos = MemoLista<Emisora>();
|
|
final _memoGrupos = MemoLista<GrupoFavoritos>();
|
|
final _memoCustom = MemoLista<Emisora>();
|
|
final _memoInicio = MemoLista<Emisora>();
|
|
final _memoDisponibles = MemoLista<Emisora>();
|
|
final _memoTimerPresets = MemoLista<int>();
|
|
static const _keyEmisoraPreferida = 'emisora_preferida_uuid_v1';
|
|
static const _keyOrdenListas = 'orden_listas_emisoras_v1';
|
|
static const _keyTimerSuenoPresets = 'timer_sueno_presets_segundos_v1';
|
|
static const _timerSuenoPresetsDefecto = <int>[
|
|
180,
|
|
300,
|
|
600,
|
|
900,
|
|
1800,
|
|
3600,
|
|
5400,
|
|
7200,
|
|
10800,
|
|
];
|
|
List<int> _timerSuenoPresetsSegundos = List<int>.from(
|
|
_timerSuenoPresetsDefecto,
|
|
);
|
|
OrdenEmisoras _ordenListas = OrdenEmisoras.calidad;
|
|
|
|
List<Emisora> get populares => _memoPopulares.obtener([
|
|
_populares,
|
|
_ordenListas,
|
|
], () => ordenarEmisoras(_populares, _ordenListas));
|
|
List<Emisora> get tendencias => _memoTendencias.obtener([
|
|
_tendencias,
|
|
_ordenListas,
|
|
], () => ordenarEmisoras(_tendencias, _ordenListas));
|
|
List<Emisora> get listaFavoritos => _memoFavoritos.obtener([
|
|
_listaFavoritos,
|
|
_ordenListas,
|
|
], () => ordenarEmisoras(_listaFavoritos, _ordenListas));
|
|
List<GrupoFavoritos> get gruposFavoritos => _memoGrupos.obtener([
|
|
_gruposFavoritos,
|
|
], () => List<GrupoFavoritos>.unmodifiable(_gruposFavoritos));
|
|
List<Emisora> get emisorasCustom => _memoCustom.obtener([
|
|
_emisorasCustom,
|
|
_ordenListas,
|
|
], () => ordenarEmisoras(_emisorasCustom, _ordenListas));
|
|
bool get cargandoPopulares => _cargandoPopulares;
|
|
String? get error => _errorCarga;
|
|
Emisora? get emisoraActual => _emisoraSeleccionada ?? audio.emisoraActual;
|
|
Emisora? get emisoraPreferida => _resolverEmisoraPreferida();
|
|
String? get emisoraPreferidaUuid => emisoraPreferida?.uuid;
|
|
Stream<EstadoReproduccion> get estadoStream => audio.estadoStream;
|
|
OrdenEmisoras get ordenListas => _ordenListas;
|
|
List<int> get timerSuenoPresetsSegundos => _memoTimerPresets.obtener([
|
|
_timerSuenoPresetsSegundos,
|
|
], () => List<int>.unmodifiable(_timerSuenoPresetsSegundos));
|
|
|
|
bool get emisoraActualEsFavorita {
|
|
final actual = emisoraActual;
|
|
if (actual == null) return false;
|
|
return _listaFavoritos.any((e) => e.uuid == actual.uuid);
|
|
}
|
|
|
|
/// Lista principal (home): custom + populares, sin duplicados.
|
|
List<Emisora> get emisorasInicio =>
|
|
_memoInicio.obtener([_emisorasCustom, _populares], () {
|
|
final mapa = <String, Emisora>{};
|
|
for (final emisora in _emisorasCustom) {
|
|
mapa[emisora.uuid] = emisora;
|
|
}
|
|
for (final emisora in _populares) {
|
|
mapa.putIfAbsent(emisora.uuid, () => emisora);
|
|
}
|
|
return mapa.values.toList();
|
|
});
|
|
|
|
List<Emisora> get emisorasDisponiblesPreferencia => _memoDisponibles.obtener(
|
|
[
|
|
_listaFavoritos,
|
|
_emisorasCustom,
|
|
_populares,
|
|
_tendencias,
|
|
busqueda.resultados,
|
|
busqueda.cercanas,
|
|
],
|
|
() {
|
|
final mapa = <String, Emisora>{};
|
|
for (final emisora in _listaFavoritos) {
|
|
mapa[emisora.uuid] = emisora;
|
|
}
|
|
for (final emisora in _emisorasCustom) {
|
|
mapa.putIfAbsent(emisora.uuid, () => emisora);
|
|
}
|
|
for (final emisora in _populares) {
|
|
mapa.putIfAbsent(emisora.uuid, () => emisora);
|
|
}
|
|
for (final emisora in _tendencias) {
|
|
mapa.putIfAbsent(emisora.uuid, () => emisora);
|
|
}
|
|
for (final emisora in busqueda.resultados) {
|
|
mapa.putIfAbsent(emisora.uuid, () => emisora);
|
|
}
|
|
for (final emisora in busqueda.cercanas) {
|
|
mapa.putIfAbsent(emisora.uuid, () => emisora);
|
|
}
|
|
return mapa.values.toList();
|
|
},
|
|
);
|
|
|
|
Future<void> inicializar() {
|
|
_initFuture ??= _init();
|
|
return _initFuture!;
|
|
}
|
|
|
|
Future<void> _init() async {
|
|
await grabacion.inicializar();
|
|
await ecualizador.cargarPersistido();
|
|
await _cargarOrdenListas();
|
|
await _cargarEmisoraPreferida();
|
|
await _cargarTimerSuenoPresets();
|
|
await Future.wait([
|
|
cargarPopulares(),
|
|
cargarFavoritos(),
|
|
cargarGruposFavoritos(),
|
|
_cargarEmisorasCustom(),
|
|
]);
|
|
await _normalizarEmisoraPreferida();
|
|
}
|
|
|
|
/// Escucha el stream de estado del audio y gestiona errores de reproducción.
|
|
void _escucharErroresReproduccion() {
|
|
_suscripcionEstadoAudio = audio.estadoStream.listen((estado) {
|
|
if (estado == EstadoReproduccion.error && timer.activo) {
|
|
unawaited(timer.cancelar());
|
|
}
|
|
if ((estado == EstadoReproduccion.detenido ||
|
|
estado == EstadoReproduccion.pausado ||
|
|
estado == EstadoReproduccion.error) &&
|
|
grabacion.activa) {
|
|
unawaited(grabacion.detener());
|
|
}
|
|
notifyListeners();
|
|
});
|
|
}
|
|
|
|
Future<void> cargarPopulares() async {
|
|
_cargandoPopulares = true;
|
|
_errorCarga = null;
|
|
notifyListeners();
|
|
try {
|
|
final results = await Future.wait([
|
|
radio.obtenerPopulares(limit: 30),
|
|
radio.obtenerTendencias(limit: 20),
|
|
]);
|
|
_populares = results[0];
|
|
_tendencias = results[1];
|
|
} catch (_) {
|
|
_errorCarga = _textos.radioApiConnectionError;
|
|
} finally {
|
|
_cargandoPopulares = false;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
Future<void> cargarFavoritos() async {
|
|
_listaFavoritos = await favoritos.obtenerTodos();
|
|
await _normalizarEmisoraPreferida();
|
|
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 {
|
|
_emisoraPreferidaUuid = emisora?.uuid;
|
|
final prefs = await _resolverPrefs();
|
|
if (_emisoraPreferidaUuid == null) {
|
|
await prefs.remove(_keyEmisoraPreferida);
|
|
} else {
|
|
await prefs.setString(_keyEmisoraPreferida, _emisoraPreferidaUuid!);
|
|
}
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> reproducirEmisoraPreferida() async {
|
|
final preferida = emisoraPreferida;
|
|
if (preferida == null) return;
|
|
await reproducir(preferida);
|
|
}
|
|
|
|
Future<void> _cargarTimerSuenoPresets() async {
|
|
try {
|
|
final prefs = await _resolverPrefs();
|
|
final raw = prefs.getString(_keyTimerSuenoPresets);
|
|
if (raw == null) return;
|
|
final decoded = jsonDecode(raw);
|
|
if (decoded is! List) return;
|
|
final presets =
|
|
decoded
|
|
.whereType<num>()
|
|
.map((n) => n.toInt())
|
|
.where((s) => s > 0)
|
|
.toSet()
|
|
.toList()
|
|
..sort();
|
|
if (presets.isNotEmpty) {
|
|
_timerSuenoPresetsSegundos = presets.take(12).toList();
|
|
}
|
|
} catch (_) {
|
|
_timerSuenoPresetsSegundos = List<int>.from(_timerSuenoPresetsDefecto);
|
|
}
|
|
}
|
|
|
|
Future<void> _cargarEmisoraPreferida() async {
|
|
final prefs = await _resolverPrefs();
|
|
_emisoraPreferidaUuid = prefs.getString(_keyEmisoraPreferida);
|
|
}
|
|
|
|
Future<void> _cargarOrdenListas() async {
|
|
final prefs = await _resolverPrefs();
|
|
final raw = prefs.getString(_keyOrdenListas);
|
|
_ordenListas = switch (raw) {
|
|
'nombre' => OrdenEmisoras.nombre,
|
|
'calidad' => OrdenEmisoras.calidad,
|
|
_ => OrdenEmisoras.calidad,
|
|
};
|
|
}
|
|
|
|
Future<void> cambiarOrdenListas(OrdenEmisoras orden) async {
|
|
_ordenListas = orden;
|
|
final prefs = await _resolverPrefs();
|
|
await prefs.setString(_keyOrdenListas, orden.name);
|
|
// Search owns its own listeners (S4-R3) but sorts with this preference.
|
|
busqueda.notificarCambioOrden();
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> _normalizarEmisoraPreferida() async {
|
|
final preferida = _resolverEmisoraPreferida();
|
|
if (preferida?.uuid == _emisoraPreferidaUuid) return;
|
|
_emisoraPreferidaUuid = preferida?.uuid;
|
|
final prefs = await _resolverPrefs();
|
|
if (_emisoraPreferidaUuid == null) {
|
|
await prefs.remove(_keyEmisoraPreferida);
|
|
} else {
|
|
await prefs.setString(_keyEmisoraPreferida, _emisoraPreferidaUuid!);
|
|
}
|
|
}
|
|
|
|
Emisora? _resolverEmisoraPreferida() {
|
|
final uuid = _emisoraPreferidaUuid;
|
|
if (uuid != null) {
|
|
for (final emisora in _listaFavoritos) {
|
|
if (emisora.uuid == uuid) return emisora;
|
|
}
|
|
}
|
|
if (_listaFavoritos.isNotEmpty) return _listaFavoritos.first;
|
|
if (uuid != null) {
|
|
for (final emisora in emisorasDisponiblesPreferencia) {
|
|
if (emisora.uuid == uuid) return emisora;
|
|
}
|
|
}
|
|
final disponibles = emisorasDisponiblesPreferencia;
|
|
return disponibles.isEmpty ? null : disponibles.first;
|
|
}
|
|
|
|
Future<void> reproducir(Emisora emisora) async {
|
|
final revision = ++_revisionReproduccion;
|
|
if (grabacion.activa) {
|
|
await grabacion.detener();
|
|
}
|
|
_emisoraSeleccionada = emisora;
|
|
notifyListeners();
|
|
try {
|
|
await audio.reproducir(emisora);
|
|
if (revision != _revisionReproduccion) return;
|
|
unawaited(radio.registrarClick(emisora.uuid));
|
|
await ecualizador.aplicarPresetActivo(
|
|
ecualizador.presetParaEmisora(emisora.uuid),
|
|
);
|
|
if (revision != _revisionReproduccion) return;
|
|
notifyListeners();
|
|
} catch (e) {
|
|
if (revision != _revisionReproduccion) return;
|
|
if (timer.activo) {
|
|
unawaited(timer.cancelar());
|
|
}
|
|
final mensajeError = e.toString().replaceFirst('Exception: ', '');
|
|
_emisoraSeleccionada = audio.emisoraActual;
|
|
_errorController.add(
|
|
mensajeError.isNotEmpty && mensajeError != 'Exception'
|
|
? mensajeError
|
|
: _textos.radioCannotPlayStation(
|
|
localizedStationName(_textos, emisora.nombre),
|
|
),
|
|
);
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
Future<void> detenerReproduccion() async {
|
|
if (grabacion.activa) {
|
|
await grabacion.detener();
|
|
}
|
|
await audio.detener();
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> togglePlay() async {
|
|
if (audio.estaSonando && grabacion.activa) {
|
|
await grabacion.detener();
|
|
}
|
|
await audio.togglePlay();
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<bool> toggleFavorito(Emisora emisora) async {
|
|
final esFav = await favoritos.toggleFavorito(emisora);
|
|
if (!esFav) {
|
|
await ecualizador.deshabilitarPresetPorEmisora(
|
|
emisora.uuid,
|
|
notificar: false,
|
|
);
|
|
}
|
|
await cargarFavoritos();
|
|
return esFav;
|
|
}
|
|
|
|
Future<bool> esFavorito(String uuid) => favoritos.esFavorito(uuid);
|
|
|
|
// ── Emisoras personalizadas ───────────────────────────────────────────────
|
|
|
|
Future<File> _archivoCustom() async {
|
|
if (_resolverArchivoCustom != null) {
|
|
return _resolverArchivoCustom();
|
|
}
|
|
final dir = await getApplicationDocumentsDirectory();
|
|
return File('${dir.path}/emisoras_custom.json');
|
|
}
|
|
|
|
Future<void> _cargarEmisorasCustom() async {
|
|
try {
|
|
final archivo = await _archivoCustom();
|
|
if (!await archivo.exists()) {
|
|
_emisorasCustom = [];
|
|
notifyListeners();
|
|
return;
|
|
}
|
|
|
|
final data = jsonDecode(await archivo.readAsString()) as List;
|
|
_emisorasCustom =
|
|
data
|
|
.map((e) => Emisora.fromMap(Map<String, dynamic>.from(e as Map)))
|
|
.toList();
|
|
} catch (_) {
|
|
_emisorasCustom = [];
|
|
}
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> _guardarEmisorasCustom() async {
|
|
final archivo = await _archivoCustom();
|
|
await archivo.writeAsString(
|
|
jsonEncode(_emisorasCustom.map((e) => e.toMap()).toList()),
|
|
);
|
|
}
|
|
|
|
Future<void> agregarEmisoraCustom(Emisora emisora) async {
|
|
// Reassign (not mutate) so identity-memoized views refresh (S4-R5).
|
|
_emisorasCustom = [
|
|
..._emisorasCustom.where((e) => e.uuid != emisora.uuid),
|
|
emisora,
|
|
];
|
|
await _guardarEmisorasCustom();
|
|
notifyListeners();
|
|
}
|
|
|
|
// Compatibilidad con el nombre histórico (typo original).
|
|
Future<void> agregarEmitoraCustom(Emisora emisora) =>
|
|
agregarEmisoraCustom(emisora);
|
|
|
|
Future<void> eliminarEmisoraCustom(String uuid) async {
|
|
_emisorasCustom = _emisorasCustom.where((e) => e.uuid != uuid).toList();
|
|
await _guardarEmisorasCustom();
|
|
notifyListeners();
|
|
}
|
|
|
|
// Compatibilidad con el nombre histórico (typo original).
|
|
Future<void> eliminarEmitoraCustom(String uuid) =>
|
|
eliminarEmisoraCustom(uuid);
|
|
|
|
// ── Export / Import ───────────────────────────────────────────────────────
|
|
|
|
static const _keyAlarmasConfig = 'alarmas_musicales_v1';
|
|
|
|
/// Genera el JSON de toda la configuración (v2 — portabilidad completa).
|
|
/// La forma del sobre v2 vive en [ServicioExportImport] (S4-R4).
|
|
Future<Map<String, dynamic>> exportarConfig() async {
|
|
final favs = await favoritos.obtenerTodos();
|
|
final grupos = await favoritos.obtenerGrupos();
|
|
final prefs = await _resolverPrefs();
|
|
|
|
// Alarmas: leemos el JSON crudo de SharedPreferences para no duplicar
|
|
// lógica de ServicioAlarmas y evitar inyectar una dependencia nueva.
|
|
final alarmasRaw = prefs.getString(_keyAlarmasConfig);
|
|
final alarmasData =
|
|
alarmasRaw != null
|
|
? jsonDecode(alarmasRaw) as Map<String, dynamic>
|
|
: null;
|
|
|
|
return _exportImport.construirExportacion(
|
|
gruposFavoritos: grupos,
|
|
favoritos: favs,
|
|
emisorasCustom: _emisorasCustom,
|
|
presetPrincipal: ecualizador.presetPrincipal,
|
|
presetsPorEmisora: ecualizador.presetsPorEmisora,
|
|
alarmas: alarmasData,
|
|
emisoraPreferidaUuid: _emisoraPreferidaUuid,
|
|
ordenListas: _ordenListas.name,
|
|
timerSuenoPresetsSegundos: _timerSuenoPresetsSegundos,
|
|
);
|
|
}
|
|
|
|
/// Exportación lista para compartir como archivo (JSON con indentación).
|
|
Future<String> exportarConfigJson() async =>
|
|
_exportImport.exportar(await exportarConfig());
|
|
|
|
/// Parsea un backup JSON; null cuando el contenido no es válido (S4-R4).
|
|
Map<String, dynamic>? parsearConfigJson(String raw) =>
|
|
_exportImport.importar(raw);
|
|
|
|
/// Importa configuración desde un JSON exportado previamente.
|
|
/// Soporta v1 (sin grupos, sin alarmas) y v2 (portabilidad completa).
|
|
Future<void> importarConfig(Map<String, dynamic> data) async {
|
|
final version = data['version'] as int? ?? 1;
|
|
if (version > 2) throw Exception(_textos.unsupportedConfigVersion);
|
|
|
|
final prefs = await _resolverPrefs();
|
|
|
|
// ── Grupos de favoritos (v2) ──────────────────────────────────────────
|
|
// Restauramos primero para que al agregar favoritos ya existan los grupos.
|
|
if (version >= 2) {
|
|
final gruposRaw = data['gruposFavoritos'] as List? ?? [];
|
|
for (final raw in gruposRaw) {
|
|
final g = GrupoFavoritos.fromMap(Map<String, dynamic>.from(raw as Map));
|
|
// Usamos insert directo para preservar id, orden y nombre originales.
|
|
await favoritos.restaurarGrupo(g);
|
|
}
|
|
await cargarGruposFavoritos();
|
|
}
|
|
|
|
// ── Favoritos ─────────────────────────────────────────────────────────
|
|
final favRaw = data['favoritos'] as List? ?? [];
|
|
for (final raw in favRaw) {
|
|
final emisora = Emisora.fromMap(Map<String, dynamic>.from(raw as Map));
|
|
await favoritos.agregar(emisora);
|
|
}
|
|
|
|
// ── Emisoras custom ───────────────────────────────────────────────────
|
|
final customRaw = data['emisorasCustom'] as List? ?? [];
|
|
_emisorasCustom =
|
|
customRaw
|
|
.map((e) => Emisora.fromMap(Map<String, dynamic>.from(e as Map)))
|
|
.toList();
|
|
await _guardarEmisorasCustom();
|
|
|
|
// ── Ecualizador ───────────────────────────────────────────────────────
|
|
final principalRaw = data['presetPrincipalEcualizador'];
|
|
final presetPrincipal =
|
|
principalRaw is Map
|
|
? PresetEcualizador.desdeJson(
|
|
Map<String, dynamic>.from(principalRaw),
|
|
)
|
|
: PresetEcualizador.flat;
|
|
|
|
final presetsRaw = data['presetsEcualizador'] as Map? ?? {};
|
|
final presetsPorEmisora = presetsRaw.map<String, PresetEcualizador>(
|
|
(uuid, presetJson) => MapEntry(
|
|
uuid as String,
|
|
PresetEcualizador.desdeJson(
|
|
Map<String, dynamic>.from(presetJson as Map),
|
|
),
|
|
),
|
|
);
|
|
|
|
await ecualizador.importarConfiguracion(
|
|
principal: presetPrincipal,
|
|
porEmisora: presetsPorEmisora,
|
|
);
|
|
|
|
// ── Alarmas (v2) ──────────────────────────────────────────────────────
|
|
if (version >= 2) {
|
|
final alarmasData = data['alarmas'];
|
|
if (alarmasData is Map<String, dynamic>) {
|
|
// Escribimos el bloque JSON tal como estaba en el dispositivo origen.
|
|
// ServicioAlarmas lo leerá con su propio fromJson al siguiente acceso.
|
|
await prefs.setString(_keyAlarmasConfig, jsonEncode(alarmasData));
|
|
}
|
|
}
|
|
|
|
// ── Preferencias de usuario (v2) ──────────────────────────────────────
|
|
if (version >= 2) {
|
|
final preferidaUuid = data['emisoraPreferidaUuid'] as String?;
|
|
_emisoraPreferidaUuid = preferidaUuid;
|
|
if (preferidaUuid == null) {
|
|
await prefs.remove(_keyEmisoraPreferida);
|
|
} else {
|
|
await prefs.setString(_keyEmisoraPreferida, preferidaUuid);
|
|
}
|
|
|
|
final ordenRaw = data['ordenListas'] as String?;
|
|
_ordenListas = switch (ordenRaw) {
|
|
'nombre' => OrdenEmisoras.nombre,
|
|
'calidad' => OrdenEmisoras.calidad,
|
|
_ => OrdenEmisoras.calidad,
|
|
};
|
|
await prefs.setString(_keyOrdenListas, _ordenListas.name);
|
|
|
|
final timerPresetsRaw = data['timerSuenoPresetsSegundos'] as List?;
|
|
if (timerPresetsRaw != null) {
|
|
await guardarTimerSuenoPresetsSegundos(
|
|
timerPresetsRaw.whereType<num>().map((n) => n.toInt()).toList(),
|
|
);
|
|
}
|
|
}
|
|
|
|
await cargarFavoritos();
|
|
notifyListeners();
|
|
}
|
|
|
|
// ── Timer ─────────────────────────────────────────────────────────────────
|
|
|
|
void iniciarTimer(int minutos) {
|
|
timer.iniciar(minutos);
|
|
notifyListeners();
|
|
}
|
|
|
|
void iniciarTimerDuracion(Duration duracion) {
|
|
timer.iniciarDuracion(duracion);
|
|
notifyListeners();
|
|
}
|
|
|
|
void cancelarTimer() {
|
|
unawaited(timer.cancelar());
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> guardarTimerSuenoPresetsSegundos(List<int> segundos) async {
|
|
final normalizados =
|
|
segundos
|
|
.where((s) => s > 0)
|
|
.map((s) => s.clamp(1, const Duration(hours: 23).inSeconds))
|
|
.toSet()
|
|
.toList()
|
|
..sort();
|
|
_timerSuenoPresetsSegundos =
|
|
normalizados.isEmpty
|
|
? List<int>.from(_timerSuenoPresetsDefecto)
|
|
: normalizados.take(12).toList();
|
|
final prefs = await _resolverPrefs();
|
|
await prefs.setString(
|
|
_keyTimerSuenoPresets,
|
|
jsonEncode(_timerSuenoPresetsSegundos),
|
|
);
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> agregarTimerSuenoPreset(Duration duracion) async {
|
|
await guardarTimerSuenoPresetsSegundos([
|
|
..._timerSuenoPresetsSegundos,
|
|
duracion.inSeconds,
|
|
]);
|
|
}
|
|
|
|
Future<void> eliminarTimerSuenoPreset(int segundos) async {
|
|
await guardarTimerSuenoPresetsSegundos(
|
|
_timerSuenoPresetsSegundos.where((s) => s != segundos).toList(),
|
|
);
|
|
}
|
|
|
|
Future<void> restaurarTimerSuenoPresets() async {
|
|
_timerSuenoPresetsSegundos = List<int>.from(_timerSuenoPresetsDefecto);
|
|
final prefs = await _resolverPrefs();
|
|
await prefs.remove(_keyTimerSuenoPresets);
|
|
notifyListeners();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_suscripcionEstadoAudio?.cancel();
|
|
_errorController.close();
|
|
ecualizador.dispose();
|
|
busqueda.dispose();
|
|
grabacion.dispose();
|
|
audio.dispose();
|
|
timer.dispose();
|
|
super.dispose();
|
|
}
|
|
}
|