feat(audio): audio session integration and runtime robustness

- Integrate audio_session (new servicio_audio_session.dart): incoming calls pause the radio and resume on end, headphone unplug pauses without auto-resume, permanent focus loss never auto-resumes, duck lowers volume
- Add play-intent flag to ServicioAudio so interruption handling and future reconnect logic can distinguish user pause from system-driven stops
- Eliminate read-modify-write race in ServicioAlarmas with an in-memory cache and single-writer queue across all mutations; recalcularTodas persists only when state actually changed
- Convert ServicioAlarmasAndroid static StreamController/handler to injectable instance fields, restoring test isolation
- Inject a single cached SharedPreferences from main.dart across services and state (removes 23 inline getInstance() calls)
- Move configurarLocalizaciones out of MiniReproductor.build() (was running on every rebuild during playback)
- Bound the alarm fire-dedup set (cap 200 entries, 24h pruning)
- 12 new tests (89 total green), flutter analyze clean
This commit is contained in:
2026-06-11 16:25:09 +02:00
parent f3e9487215
commit 079e19f0ee
21 changed files with 1059 additions and 151 deletions
+28 -19
View File
@@ -16,7 +16,6 @@ import '../l10n/gen/app_localizations.dart';
import '../modelos/emisora.dart';
import '../modelos/grupo_favoritos.dart';
import '../modelos/preset_ecualizador.dart';
import '../servicios/servicio_alarmas_android.dart';
import '../servicios/servicio_audio.dart';
import '../servicios/servicio_ecualizador.dart';
import '../servicios/servicio_favoritos.dart';
@@ -38,13 +37,16 @@ class EstadoRadio extends ChangeNotifier {
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(),
grabacion = servicioGrabacion ?? ServicioGrabacionRadio(),
servicioEcualizador =
servicioEcualizador ?? ServicioEcualizador(prefs: prefs),
grabacion = servicioGrabacion ?? ServicioGrabacionRadio(prefs: prefs),
_prefs = prefs,
_resolverArchivoCustom = resolverArchivoCustom {
timer = ServicioTimer(this.audio);
_escucharErroresReproduccion();
@@ -59,8 +61,14 @@ class EstadoRadio extends ChangeNotifier {
final ServicioRadio radio;
final ServicioEcualizador servicioEcualizador;
final ServicioGrabacionRadio grabacion;
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;
@@ -71,7 +79,9 @@ class EstadoRadio extends ChangeNotifier {
_l10n = l10n;
audio.configurarLocalizaciones(l10n);
grabacion.configurarLocalizaciones(l10n);
ServicioAlarmasAndroid.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;
@@ -332,7 +342,7 @@ class EstadoRadio extends ChangeNotifier {
Future<void> cambiarEmisoraPreferida(Emisora? emisora) async {
_emisoraPreferidaUuid = emisora?.uuid;
final prefs = await SharedPreferences.getInstance();
final prefs = await _resolverPrefs();
if (_emisoraPreferidaUuid == null) {
await prefs.remove(_keyEmisoraPreferida);
} else {
@@ -349,7 +359,7 @@ class EstadoRadio extends ChangeNotifier {
Future<void> _cargarTimerSuenoPresets() async {
try {
final prefs = await SharedPreferences.getInstance();
final prefs = await _resolverPrefs();
final raw = prefs.getString(_keyTimerSuenoPresets);
if (raw == null) return;
final decoded = jsonDecode(raw);
@@ -371,12 +381,12 @@ class EstadoRadio extends ChangeNotifier {
}
Future<void> _cargarEmisoraPreferida() async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _resolverPrefs();
_emisoraPreferidaUuid = prefs.getString(_keyEmisoraPreferida);
}
Future<void> _cargarOrdenListas() async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _resolverPrefs();
final raw = prefs.getString(_keyOrdenListas);
_ordenListas = switch (raw) {
'nombre' => OrdenEmisoras.nombre,
@@ -387,7 +397,7 @@ class EstadoRadio extends ChangeNotifier {
Future<void> cambiarOrdenListas(OrdenEmisoras orden) async {
_ordenListas = orden;
final prefs = await SharedPreferences.getInstance();
final prefs = await _resolverPrefs();
await prefs.setString(_keyOrdenListas, orden.name);
notifyListeners();
}
@@ -396,7 +406,7 @@ class EstadoRadio extends ChangeNotifier {
final preferida = _resolverEmisoraPreferida();
if (preferida?.uuid == _emisoraPreferidaUuid) return;
_emisoraPreferidaUuid = preferida?.uuid;
final prefs = await SharedPreferences.getInstance();
final prefs = await _resolverPrefs();
if (_emisoraPreferidaUuid == null) {
await prefs.remove(_keyEmisoraPreferida);
} else {
@@ -905,23 +915,22 @@ class EstadoRadio extends ChangeNotifier {
Future<Map<String, dynamic>> exportarConfig() async {
final favs = await favoritos.obtenerTodos();
final grupos = await favoritos.obtenerGrupos();
final prefs = await SharedPreferences.getInstance();
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;
alarmasRaw != null
? jsonDecode(alarmasRaw) as Map<String, dynamic>
: null;
return {
'version': 2,
'exportedAt': DateTime.now().toIso8601String(),
// Favoritos + grupos (preserva asignaciones grupo_id en cada emisora)
'gruposFavoritos':
grupos
.where((g) => !g.esSinAsignar)
.map((g) => g.toMap())
.toList(),
grupos.where((g) => !g.esSinAsignar).map((g) => g.toMap()).toList(),
'favoritos': favs.map((e) => e.toMap()).toList(),
// Emisoras personalizadas
'emisorasCustom': _emisorasCustom.map((e) => e.toMap()).toList(),
@@ -945,7 +954,7 @@ class EstadoRadio extends ChangeNotifier {
final version = data['version'] as int? ?? 1;
if (version > 2) throw Exception(_textos.unsupportedConfigVersion);
final prefs = await SharedPreferences.getInstance();
final prefs = await _resolverPrefs();
// ── Grupos de favoritos (v2) ──────────────────────────────────────────
// Restauramos primero para que al agregar favoritos ya existan los grupos.
@@ -1080,7 +1089,7 @@ class EstadoRadio extends ChangeNotifier {
normalizados.isEmpty
? List<int>.from(_timerSuenoPresetsDefecto)
: normalizados.take(12).toList();
final prefs = await SharedPreferences.getInstance();
final prefs = await _resolverPrefs();
await prefs.setString(
_keyTimerSuenoPresets,
jsonEncode(_timerSuenoPresetsSegundos),
@@ -1103,7 +1112,7 @@ class EstadoRadio extends ChangeNotifier {
Future<void> restaurarTimerSuenoPresets() async {
_timerSuenoPresetsSegundos = List<int>.from(_timerSuenoPresetsDefecto);
final prefs = await SharedPreferences.getInstance();
final prefs = await _resolverPrefs();
await prefs.remove(_keyTimerSuenoPresets);
notifyListeners();
}