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
+53 -4
View File
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/alarma_musical.dart';
import '../servicios/servicio_alarmas.dart';
import '../servicios/servicio_alarmas_android.dart';
@@ -13,7 +14,7 @@ class EstadoAlarmas extends ChangeNotifier {
PuertoAlarmasAndroid? android,
SharedPreferences? prefs,
bool iniciarAutomaticamente = true,
}) : servicio = servicio ?? ServicioAlarmas(),
}) : servicio = servicio ?? ServicioAlarmas(prefs: prefs),
android = android ?? ServicioAlarmasAndroid(),
_prefs = prefs {
// Decision 2.1 (snooze sync): the native layer reports its own snoozes
@@ -43,6 +44,12 @@ class EstadoAlarmas extends ChangeNotifier {
StreamController<AlarmaMusical>.broadcast();
final Set<String> _ejecucionesEmitidas = {};
static const _margenDisparoLocal = Duration(seconds: 45);
// Bounds for _ejecucionesEmitidas (S3-R6): entries older than the
// retention window are pruned; the set never exceeds the cap.
static const _retencionEjecucionesEmitidas = Duration(hours: 24);
@visibleForTesting
static const maxEjecucionesEmitidas = 200;
bool _cargando = false;
String? _error;
@@ -128,12 +135,22 @@ class EstadoAlarmas extends ChangeNotifier {
final proxima = alarma.proximaProgramable;
if (proxima == null) return;
final key = '${alarma.id}:${proxima.millisecondsSinceEpoch}';
_ejecucionesEmitidas.add(key);
_registrarEjecucionEmitida(key);
debugPrint(
'[PluriWave][alarmas] ejecucion gestionada id=${alarma.id} proxima=${proxima.toIso8601String()}',
);
}
@visibleForTesting
int get ejecucionesEmitidasLength => _ejecucionesEmitidas.length;
/// Forwards the UI localizations to the native bridge so alarm and station
/// names sent to Android follow the app locale (Decision 3.2 — replaces
/// the old static `ServicioAlarmasAndroid.configurarLocalizaciones`).
void configurarLocalizaciones(AppLocalizations l10n) {
android.configurarLocalizaciones(l10n);
}
Future<void> eliminarAlarma(String id) async {
debugPrint('[PluriWave][alarmas] eliminar id=$id');
final config = await servicio.eliminarAlarma(id);
@@ -415,6 +432,7 @@ class EstadoAlarmas extends ChangeNotifier {
void _vigilarAlarmasVencidas() {
final ahora = DateTime.now();
_depurarEjecucionesEmitidas(ahora);
for (final alarma in _alarmas) {
final proxima = alarma.proximaProgramable;
if (!alarma.activa || proxima == null) continue;
@@ -422,13 +440,13 @@ class EstadoAlarmas extends ChangeNotifier {
final key = '${alarma.id}:${proxima.millisecondsSinceEpoch}';
final retraso = ahora.difference(proxima);
if (retraso > _margenDisparoLocal) {
_ejecucionesEmitidas.add(key);
_registrarEjecucionEmitida(key);
debugPrint(
'[PluriWave][alarmas] vencida local ignorada por antigua id=${alarma.id} proxima=${proxima.toIso8601String()} retraso=${retraso.inSeconds}s',
);
continue;
}
if (_ejecucionesEmitidas.add(key)) {
if (_registrarEjecucionEmitida(key)) {
debugPrint(
'[PluriWave][alarmas] vencida local id=${alarma.id} proxima=${proxima.toIso8601String()}',
);
@@ -437,6 +455,37 @@ class EstadoAlarmas extends ChangeNotifier {
}
}
/// Adds a `alarmId:millis` key and keeps the set bounded (S3-R6).
/// Returns whether the key was newly added (fire-dedup contract).
bool _registrarEjecucionEmitida(String key) {
final agregada = _ejecucionesEmitidas.add(key);
_depurarEjecucionesEmitidas(DateTime.now());
return agregada;
}
void _depurarEjecucionesEmitidas(DateTime ahora) {
final limite =
ahora.subtract(_retencionEjecucionesEmitidas).millisecondsSinceEpoch;
_ejecucionesEmitidas.removeWhere((key) => _millisDeEjecucion(key) < limite);
if (_ejecucionesEmitidas.length <= maxEjecucionesEmitidas) return;
// Still over the cap: evict the oldest occurrences first. Pruned keys
// cannot re-fire because occurrences beyond _margenDisparoLocal are
// ignored by _vigilarAlarmasVencidas anyway.
final ordenadas =
_ejecucionesEmitidas.toList()..sort(
(a, b) => _millisDeEjecucion(a).compareTo(_millisDeEjecucion(b)),
);
_ejecucionesEmitidas.removeAll(
ordenadas.take(_ejecucionesEmitidas.length - maxEjecucionesEmitidas),
);
}
int _millisDeEjecucion(String key) {
final separador = key.lastIndexOf(':');
if (separador < 0) return 0;
return int.tryParse(key.substring(separador + 1)) ?? 0;
}
@override
void dispose() {
_refresco?.cancel();