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:
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user