Adds a permanent, non-consumable premium unlock (EstadoEntitlement + PuertoCompras/ServicioComprasPlayBilling) that removes ads and unlocks alarm vacations, alarms past a 5-alarm free cap, recording start, and full Android Auto browsing. The phone equalizer stays free for everyone. - Entitlement is prefs-backed (compra_premium_v1), fail-open, and resolvable headlessly via esPremiumPersistido() for the Android Auto audio handler, which registers before runApp. - Android Auto reduced mode keeps the real root folder labels for free users; browsing into any of them (and playFromMediaId/playFromSearch/ skipToNext/skipToPrevious) is blocked at the getChildren/servicio_audio choke points, with a locked "Función Premium" item as the backstop. Current-station play/pause/stop stays untouched. A free -> premium transition actively invalidates the head unit's cached browse tree. - Ads (top banner + capped interstitial before adding a station or an alarm) are gated behind entitlement via ServicioAnuncios, using official Google test ad unit IDs pending AdMob provisioning. - Alarm cap UX shows an explanatory message with a secondary unlock action rather than a bare paywall jump; existing data is grandfathered. - 4 new localization keys translated across all 13 supported locales. Co-located tests use strict TDD (RED test before implementation) for every new pure-logic unit; full existing suite passes unchanged.
1053 lines
40 KiB
Dart
1053 lines
40 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/navegacion_auto.dart';
|
|
import '../servicios/persistencia_tolerante.dart';
|
|
import '../servicios/servicio_audio.dart';
|
|
import '../servicios/servicio_dispositivo_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,
|
|
ServicioDispositivoAudio? dispositivoAudio,
|
|
ServicioGrabacionRadio? servicioGrabacion,
|
|
SharedPreferences? prefs,
|
|
Future<File> Function()? resolverArchivoCustom,
|
|
FuenteEmisorasAuto? fuenteAuto,
|
|
bool iniciarAutomaticamente = true,
|
|
// iap-freemium-unlock (Design ADR-3): threaded straight through to the
|
|
// internal `EstadoGrabacion` below — `EstadoRadio` itself has no gated
|
|
// behavior of its own.
|
|
bool Function()? esPremium,
|
|
}) : audio = audio ?? ServicioAudio(),
|
|
favoritos = favoritos ?? ServicioFavoritos(),
|
|
radio = radio ?? ServicioRadio(),
|
|
servicioEcualizador =
|
|
servicioEcualizador ?? ServicioEcualizador(prefs: prefs),
|
|
_dispositivoAudio = dispositivoAudio,
|
|
_prefs = prefs,
|
|
_resolverArchivoCustom = resolverArchivoCustom,
|
|
_fuenteAuto = fuenteAuto {
|
|
ecualizador = EstadoEcualizador(
|
|
audio: this.audio,
|
|
servicio: this.servicioEcualizador,
|
|
dispositivoAudio: _dispositivoAudio,
|
|
emisoraActualUuid: () => emisoraActual?.uuid,
|
|
);
|
|
grabacion = EstadoGrabacion(
|
|
servicio: servicioGrabacion ?? ServicioGrabacionRadio(prefs: prefs),
|
|
emisoraActual: () => emisoraActual,
|
|
alError: _errorController.add,
|
|
esPremium: esPremium,
|
|
);
|
|
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;
|
|
|
|
/// Optional device service — null means no device subscription established.
|
|
/// Wired in production from app.dart; defaults to null in tests.
|
|
final ServicioDispositivoAudio? _dispositivoAudio;
|
|
|
|
/// 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;
|
|
|
|
/// Android Auto browse source (Design "live snapshot the source
|
|
/// prefers"). Optional and unused by default — wired from main.dart via
|
|
/// the [FuenteEmisorasAutoLocal] instance registered into the handler.
|
|
/// When set, this instance's in-memory lists are pushed on every mutation
|
|
/// so the car sees the same data as the phone without a duplicate read.
|
|
final FuenteEmisorasAuto? _fuenteAuto;
|
|
|
|
/// 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 = [];
|
|
|
|
// persistence-resilience (D5): set when the custom-stations file EXISTS
|
|
// but could not be READ at the OS level (vs. a parse failure, which
|
|
// quarantines the file instead -- see _ponerEnCuarentena). While true,
|
|
// _guardarEmisorasCustom is suppressed for the rest of this session: we
|
|
// cannot tell whether the underlying file is actually intact, so we never
|
|
// risk clobbering it with an empty in-memory list. Unlike the Alarms
|
|
// degraded flag, this is intentionally NOT cleared by an explicit
|
|
// add/remove -- only the NEXT clean/partial load in _cargarEmisorasCustom
|
|
// clears it (D5's asymmetry: a transiently-unreadable file may still be
|
|
// intact on disk).
|
|
bool _customDegradado = false;
|
|
|
|
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 _memoFavoritosManual = 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';
|
|
// Issue 4 (feedback-pruebas): last-played station, so the Escuchar hero
|
|
// keeps showing "what I was listening to" (stopped, not playing) after a
|
|
// full app restart instead of going empty.
|
|
static const _keyUltimaEmisora = 'ultima_emisora_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));
|
|
|
|
/// WU4, `favorites-organization` spec: Favoritos' own manual order —
|
|
/// unlike [listaFavoritos], this is NOT re-sorted by the global
|
|
/// [ordenListas] setting on every read. It reflects exactly the order
|
|
/// stored via the `orden` column (`ServicioFavoritos.obtenerTodos()`),
|
|
/// which [reordenarFavorito] and [ordenarFavoritos] mutate. Other
|
|
/// consumers of `listaFavoritos` (Android Auto, the Escuchar grid) are
|
|
/// unaffected by drag-reordering Favoritos.
|
|
List<Emisora> get listaFavoritosManual => _memoFavoritosManual.obtener([
|
|
_listaFavoritos,
|
|
], () => List<Emisora>.unmodifiable(_listaFavoritos));
|
|
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();
|
|
await _restaurarUltimaEmisora();
|
|
}
|
|
|
|
/// Issue 4 (feedback-pruebas): restores the last-played station as a
|
|
/// STOPPED `emisoraActual` on a cold start. Only fills the gap — if
|
|
/// something is ALREADY selected (a real play already ran concurrently),
|
|
/// this is a no-op. Never touches `audio`: no playback starts, no network
|
|
/// request is made, `estadoStream`/`estaSonando` stay at their fresh
|
|
/// "detenido" default, exactly like every other consumer of
|
|
/// `emisoraActual` already expects (they gate "is it playing" on the
|
|
/// separate playback-status stream, never on `emisoraActual != null`).
|
|
Future<void> _restaurarUltimaEmisora() async {
|
|
if (_emisoraSeleccionada != null || audio.emisoraActual != null) return;
|
|
try {
|
|
final prefs = await _resolverPrefs();
|
|
final raw = prefs.getString(_keyUltimaEmisora);
|
|
if (raw == null) return;
|
|
final mapa = jsonDecode(raw) as Map<String, dynamic>;
|
|
_emisoraSeleccionada = Emisora.fromMap(mapa);
|
|
} catch (e) {
|
|
registrarSaltoPersistencia(
|
|
subsistema: 'ultima_emisora',
|
|
detalle: 'restaurar',
|
|
razon: e.toString(),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Best-effort remembers [emisora] as the last used station (issue 4) so
|
|
/// [_restaurarUltimaEmisora] can bring it back after a restart. Fire-and-
|
|
/// forget, same treatment [reproducir] already gives other non-critical
|
|
/// side effects (e.g. `radio.registrarClick`) — a failed write here must
|
|
/// never block or fail actual playback.
|
|
Future<void> _persistirUltimaEmisora(Emisora emisora) async {
|
|
try {
|
|
final prefs = await _resolverPrefs();
|
|
await prefs.setString(_keyUltimaEmisora, jsonEncode(emisora.toMap()));
|
|
} catch (e) {
|
|
registrarSaltoPersistencia(
|
|
subsistema: 'ultima_emisora',
|
|
detalle: 'persistir ${emisora.uuid}',
|
|
razon: e.toString(),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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());
|
|
}
|
|
// Design "playback coherence with EstadoRadio": a car-initiated
|
|
// selection (Android Auto's playFromMediaId) changes audio.emisoraActual
|
|
// directly, bypassing reproducir(). Without this, _emisoraSeleccionada
|
|
// would keep shadowing the car's station on emisoraActual's getter.
|
|
final actual = audio.emisoraActual;
|
|
if (actual != null && actual.uuid != _emisoraSeleccionada?.uuid) {
|
|
_emisoraSeleccionada = actual;
|
|
// Issue 4: an Android-Auto-initiated selection is a real station
|
|
// change too — remember it the same way `reproducir` does.
|
|
unawaited(_persistirUltimaEmisora(actual));
|
|
}
|
|
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;
|
|
// Design "live snapshot the source prefers": Android Auto's `Todas`
|
|
// folder mirrors the same populares list the phone just loaded.
|
|
//
|
|
// Fix `android-auto-orden`: pushes the SORTED [populares] getter, not
|
|
// the raw [_populares] field — the same [_ordenListas] setting the
|
|
// phone's own discovery lists (e.g. Buscar's `tendencias`) already
|
|
// sort by must also govern this folder's order, not the API's raw
|
|
// arrival order. `navegacion_auto.dart`'s `hijos()` no longer
|
|
// re-sorts, so whatever order arrives here IS what the driver sees.
|
|
_fuenteAuto?.actualizarSnapshot(todas: populares);
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
Future<void> cargarFavoritos() async {
|
|
_listaFavoritos = await favoritos.obtenerTodos();
|
|
await _normalizarEmisoraPreferida();
|
|
// Fix `android-auto-orden`: pushes the documented manual-order accessor
|
|
// explicitly. [listaFavoritosManual] is backed by the same list as
|
|
// [_listaFavoritos] today (obtenerTodos() already returns the persisted
|
|
// manual order), but naming the intent here — "the exact order the
|
|
// Favoritos screen shows and reorders" — keeps this call from silently
|
|
// drifting onto a re-sorted list in a future refactor.
|
|
_fuenteAuto?.actualizarSnapshot(favoritos: listaFavoritosManual);
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> cargarGruposFavoritos() async {
|
|
_gruposFavoritos = await favoritos.obtenerGrupos();
|
|
// Design "live snapshot the source prefers": Android Auto's grouped
|
|
// Favoritos tree mirrors the same group list the phone just loaded.
|
|
_fuenteAuto?.actualizarSnapshot(grupos: _gruposFavoritos);
|
|
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();
|
|
}
|
|
|
|
/// WU4: persists a single drag-to-reorder move within [listaFavoritosManual].
|
|
/// [nuevoIndice] is the absolute target position among ALL favorites (not
|
|
/// scoped to any chip filter) — the screen translates a filtered-list drag
|
|
/// into this global index before calling this method.
|
|
Future<void> reordenarFavorito(String uuid, int nuevoIndice) async {
|
|
await favoritos.reordenar(uuid, nuevoIndice);
|
|
await cargarFavoritos();
|
|
}
|
|
|
|
/// WU4: the Favoritos `swap_vert` sort action. Reuses the existing
|
|
/// [OrdenEmisoras] criteria and [ordenarEmisoras] function — "It MUST NOT
|
|
/// introduce a sort criterion with no backing implementation"
|
|
/// (favorites-organization spec). Unlike [ordenListas] (a GLOBAL setting
|
|
/// affecting favorites/searches/nearby/quick-lists), this sorts ONLY the
|
|
/// favorites list once and PERSISTS the result as the new manual order —
|
|
/// consistent with [reordenarFavorito]'s "the order MUST persist" contract.
|
|
Future<void> ordenarFavoritos(OrdenEmisoras criterio) async {
|
|
final ordenados = ordenarEmisoras(_listaFavoritos, criterio);
|
|
for (var i = 0; i < ordenados.length; i++) {
|
|
await favoritos.reordenar(ordenados[i].uuid, i);
|
|
}
|
|
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,
|
|
'popularidad' => OrdenEmisoras.popularidad,
|
|
_ => 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();
|
|
// Fix `android-auto-orden`: Todas/Mis emisoras' Android Auto order is
|
|
// derived from this same setting (see cargarPopulares/
|
|
// _cargarEmisorasCustom above) — without an immediate re-push, a live
|
|
// car session would keep showing the OLD order until the next full
|
|
// reload instead of updating right away, same as the phone does via
|
|
// this method's own memoized getters.
|
|
_fuenteAuto?.actualizarSnapshot(
|
|
todas: populares,
|
|
misEmisoras: emisorasCustom,
|
|
);
|
|
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();
|
|
// Issue 4: remembers the station the user just picked so it survives a
|
|
// restart — fire-and-forget, same treatment as `radio.registrarClick`
|
|
// below (a persistence failure here must never block playback).
|
|
unawaited(_persistirUltimaEmisora(emisora));
|
|
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');
|
|
}
|
|
|
|
/// Loads the custom-stations file with per-entry tolerance
|
|
/// (persistence-resilience D1/D5). Two DIFFERENT failure kinds get TWO
|
|
/// different treatments because they carry different guarantees about
|
|
/// whether the file itself is still intact:
|
|
/// - the file cannot be READ at the OS level (see [_leerContenidoCustom])
|
|
/// -> IO-fail: unknown whether the file is intact, so it is left
|
|
/// completely untouched and [_customDegradado] suppresses writes.
|
|
/// - the file reads fine but its top-level JSON/shape is invalid -> the
|
|
/// bytes we DID manage to read are definitely the corrupt culprit, so
|
|
/// they are quarantined into a `.corrupt` sidecar and the live path is
|
|
/// cleared for the next write ([_ponerEnCuarentena]); no flag needed,
|
|
/// the cleared path is itself the "authority restored" signal.
|
|
/// - per-entry failures inside an otherwise-valid list are handled by the
|
|
/// shared [parseListaTolerante] (D1): survivors are kept, no flag/
|
|
/// quarantine at all.
|
|
Future<void> _cargarEmisorasCustom() async {
|
|
// Resolving the path itself is part of the IO surface: a throw here must
|
|
// get the same IO-fail treatment as an unreadable file, not escape into
|
|
// _init()'s Future.wait and take the sibling loads down with it.
|
|
final File archivo;
|
|
try {
|
|
archivo = await _archivoCustom();
|
|
} catch (e) {
|
|
_customDegradado = true;
|
|
registrarSaltoPersistencia(
|
|
subsistema: 'emisoras_custom',
|
|
detalle: 'resolucion de ruta',
|
|
razon: e.toString(),
|
|
);
|
|
// Fix `android-auto-orden`: pushes the SORTED [emisorasCustom] getter
|
|
// (see the doc on this method's other 3 identical call sites below).
|
|
_fuenteAuto?.actualizarSnapshot(misEmisoras: emisorasCustom);
|
|
notifyListeners();
|
|
return;
|
|
}
|
|
final contenido = await _leerContenidoCustom(archivo);
|
|
if (contenido == null) return; // ya resuelto: vacio o degradado por IO.
|
|
|
|
try {
|
|
final data = jsonDecode(contenido) as List;
|
|
final resultado = parseListaTolerante<Emisora>(
|
|
data,
|
|
Emisora.fromMap,
|
|
subsistema: 'emisoras_custom',
|
|
coleccion: 'emisoras_custom',
|
|
);
|
|
_emisorasCustom = resultado.validas;
|
|
_customDegradado = false;
|
|
} catch (e) {
|
|
await _ponerEnCuarentena(archivo);
|
|
_emisorasCustom = [];
|
|
registrarSaltoPersistencia(
|
|
subsistema: 'emisoras_custom',
|
|
detalle: archivo.path,
|
|
razon: e.toString(),
|
|
);
|
|
}
|
|
// Fix `android-auto-orden`: sorted getter, not the raw field.
|
|
_fuenteAuto?.actualizarSnapshot(misEmisoras: emisorasCustom);
|
|
notifyListeners();
|
|
}
|
|
|
|
/// Reads the raw content of [archivo]; returns null when the outcome was
|
|
/// already fully resolved here, so the caller has nothing left to parse:
|
|
/// - the file does not exist -> healthy empty state (unchanged behavior).
|
|
/// - `exists()`/`readAsString()` throws -> IO-fail (D5): the file is left
|
|
/// untouched (we cannot know if it is actually intact) and
|
|
/// [_customDegradado] suppresses [_guardarEmisorasCustom] for the rest
|
|
/// of this session.
|
|
Future<String?> _leerContenidoCustom(File archivo) async {
|
|
try {
|
|
if (!await archivo.exists()) {
|
|
_emisorasCustom = [];
|
|
_customDegradado = false;
|
|
// Fix `android-auto-orden`: sorted getter, not the raw field.
|
|
_fuenteAuto?.actualizarSnapshot(misEmisoras: emisorasCustom);
|
|
notifyListeners();
|
|
return null;
|
|
}
|
|
return await archivo.readAsString();
|
|
} catch (e) {
|
|
_emisorasCustom = [];
|
|
_customDegradado = true;
|
|
registrarSaltoPersistencia(
|
|
subsistema: 'emisoras_custom',
|
|
detalle: archivo.path,
|
|
razon: e.toString(),
|
|
);
|
|
// Fix `android-auto-orden`: sorted getter, not the raw field.
|
|
_fuenteAuto?.actualizarSnapshot(misEmisoras: emisorasCustom);
|
|
notifyListeners();
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Moves an unparseable custom-stations file out of the live path so the
|
|
/// next add/remove starts fresh. If a `.corrupt` sidecar from a PREVIOUS
|
|
/// quarantine already exists, that earlier payload is preserved untouched
|
|
/// and the newly-corrupt live file is simply dropped (D5) -- a sidecar
|
|
/// only ever holds the OLDEST unresolved quarantine, never overwritten by
|
|
/// a newer one.
|
|
Future<void> _ponerEnCuarentena(File archivo) async {
|
|
final sidecar = File('${archivo.path}.corrupt');
|
|
if (await sidecar.exists()) {
|
|
await archivo.delete();
|
|
} else {
|
|
await archivo.rename(sidecar.path);
|
|
}
|
|
}
|
|
|
|
Future<void> _guardarEmisorasCustom() async {
|
|
// persistence-resilience (D5): never write while an IO-degraded read
|
|
// means we cannot be sure the on-disk file is still intact.
|
|
if (_customDegradado) return;
|
|
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 (v3 — portabilidad completa
|
|
/// con presets por dispositivo y matriz multi-device).
|
|
/// La forma del sobre v3 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,
|
|
// v3 extensions — multi-device EQ fields.
|
|
presetsPorDispositivo: ecualizador.presetsDispositivo,
|
|
presetsMatriz: ecualizador.presetsMatriz,
|
|
eqMultiDeviceEnabled: ecualizador.eqMultiDeviceEnabled,
|
|
);
|
|
}
|
|
|
|
/// 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), v2 (portabilidad completa),
|
|
/// y v3 (+ presets por dispositivo, presets matriz, toggle multi-device).
|
|
Future<void> importarConfig(Map<String, dynamic> data) async {
|
|
final version = data['version'] as int? ?? 1;
|
|
if (version > 3) 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),
|
|
),
|
|
),
|
|
);
|
|
|
|
// v3 extensions: per-device and matrix presets.
|
|
Map<String, PresetEcualizador>? presetsDispositivo;
|
|
Map<String, PresetEcualizador>? presetsMatriz;
|
|
bool? eqMultiDeviceEnabled;
|
|
|
|
if (version >= 3) {
|
|
final dispositivoRaw = data['presetsPorDispositivo'] as Map? ?? {};
|
|
presetsDispositivo = dispositivoRaw.map<String, PresetEcualizador>(
|
|
(deviceId, presetJson) => MapEntry(
|
|
deviceId as String,
|
|
PresetEcualizador.desdeJson(
|
|
Map<String, dynamic>.from(presetJson as Map),
|
|
),
|
|
),
|
|
);
|
|
|
|
final matrizRaw = data['presetsMatriz'] as Map? ?? {};
|
|
presetsMatriz = matrizRaw.map<String, PresetEcualizador>(
|
|
(clave, presetJson) => MapEntry(
|
|
clave as String,
|
|
PresetEcualizador.desdeJson(
|
|
Map<String, dynamic>.from(presetJson as Map),
|
|
),
|
|
),
|
|
);
|
|
|
|
eqMultiDeviceEnabled = data['eqMultiDeviceEnabled'] as bool?;
|
|
}
|
|
|
|
await ecualizador.importarConfiguracion(
|
|
principal: presetPrincipal,
|
|
porEmisora: presetsPorEmisora,
|
|
presetsDispositivo: presetsDispositivo,
|
|
presetsMatriz: presetsMatriz,
|
|
eqMultiDeviceEnabled: eqMultiDeviceEnabled,
|
|
);
|
|
|
|
// ── 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,
|
|
'popularidad' => OrdenEmisoras.popularidad,
|
|
_ => 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();
|
|
_dispositivoAudio?.dispose();
|
|
super.dispose();
|
|
}
|
|
}
|