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
+107 -57
View File
@@ -34,9 +34,40 @@ class ServicioAlarmas {
final DateTime Function() _reloj;
final _uuid = const Uuid();
Future<ConfiguracionAlarmas> cargar() async {
// In-memory cache + single-writer queue (Design 3.5 / S3-R7): every
// mutation runs serialized through [_enCola] and reads [_cache], killing
// the read-modify-write race the old cargar()-before-each-mutation had.
ConfiguracionAlarmas? _cache;
String? _cacheRaw;
Future<void> _cola = Future<void>.value();
Future<T> _enCola<T>(Future<T> Function() accion) {
final resultado = _cola.then((_) => accion());
_cola = resultado.then((_) {}, onError: (_) {});
return resultado;
}
/// Re-reads from persistent storage (refreshing the cache) so writes done
/// outside this service — e.g. a settings import that rewrites the raw
/// key — are always picked up.
Future<ConfiguracionAlarmas> cargar() => _enCola(() {
_cache = null;
_cacheRaw = null;
return _configActual();
});
Future<ConfiguracionAlarmas> _configActual() async {
final existente = _cache;
if (existente != null) return existente;
final prefs = await _resolverPrefs();
final raw = prefs.getString(_keyConfig);
final config = _parsear(raw);
_cache = config;
_cacheRaw = raw;
return config;
}
ConfiguracionAlarmas _parsear(String? raw) {
if (raw == null || raw.trim().isEmpty) {
return const ConfiguracionAlarmas(
alarmas: [],
@@ -82,8 +113,8 @@ class ServicioAlarmas {
AlarmaMusical alarma, {
List<RangoVacaciones>? vacaciones,
List<ExcepcionAlarma>? excepciones,
}) async {
final config = await cargar();
}) => _enCola(() async {
final config = await _configActual();
final ahora = _reloj();
final alarmas = List<AlarmaMusical>.from(config.alarmas);
final index = alarmas.indexWhere((a) => a.id == alarma.id);
@@ -105,10 +136,10 @@ class ServicioAlarmas {
);
await _guardar(nuevo);
return nuevo;
}
});
Future<ConfiguracionAlarmas> eliminarAlarma(String id) async {
final config = await cargar();
Future<ConfiguracionAlarmas> eliminarAlarma(String id) => _enCola(() async {
final config = await _configActual();
final nuevo = ConfiguracionAlarmas(
alarmas: config.alarmas.where((a) => a.id != id).toList(),
vacaciones: config.vacaciones,
@@ -116,12 +147,12 @@ class ServicioAlarmas {
);
await _guardar(nuevo);
return nuevo;
}
});
Future<ConfiguracionAlarmas> guardarVacaciones(
List<RangoVacaciones> vacaciones,
) async {
final config = await cargar();
) => _enCola(() async {
final config = await _configActual();
final normalizadas =
vacaciones.map((v) => v.normalizado()).toList()
..sort((a, b) => a.inicioDia.compareTo(b.inicioDia));
@@ -136,7 +167,7 @@ class ServicioAlarmas {
);
await _guardar(nuevo);
return nuevo;
}
});
RangoVacaciones crearRangoVacaciones({
required DateTime inicio,
@@ -155,8 +186,8 @@ class ServicioAlarmas {
return rango.normalizado();
}
Future<ConfiguracionAlarmas> recalcularTodas() async {
final config = await cargar();
Future<ConfiguracionAlarmas> recalcularTodas() => _enCola(() async {
final config = await _configActual();
final alarmas =
config.alarmas
.map((a) => _recalcular(a, config.vacaciones, config.excepciones))
@@ -166,16 +197,26 @@ class ServicioAlarmas {
vacaciones: config.vacaciones,
excepciones: config.excepciones,
);
await _guardar(nuevo);
// Dirty-guard (S3-R5): this runs every minute from the refresh timer;
// skip the SharedPreferences write when nothing actually changed.
final nuevoRaw = _serializar(nuevo);
final actualRaw = _cacheRaw ?? _serializar(config);
if (nuevoRaw == actualRaw) return config;
await _guardar(nuevo, raw: nuevoRaw);
return nuevo;
}
});
Future<ConfiguracionAlarmas> sincronizarEjecucionesNativas(
Map<String, DateTime> ejecuciones,
) async {
) {
if (ejecuciones.isEmpty) return cargar();
return _enCola(() => _sincronizarEjecucionesNativasInterno(ejecuciones));
}
final config = await cargar();
Future<ConfiguracionAlarmas> _sincronizarEjecucionesNativasInterno(
Map<String, DateTime> ejecuciones,
) async {
final config = await _configActual();
final ahora = _reloj();
var huboCambios = false;
final alarmas =
@@ -225,33 +266,38 @@ class ServicioAlarmas {
return nuevo;
}
Future<ConfiguracionAlarmas> saltarProxima(String alarmaId) async {
final config = await cargar();
final alarma = config.alarmas.firstWhere((a) => a.id == alarmaId);
final proxima = alarma.proximaEjecucion;
if (proxima == null) return config;
Future<ConfiguracionAlarmas> saltarProxima(String alarmaId) =>
_enCola(() async {
final config = await _configActual();
final alarma = config.alarmas.firstWhere((a) => a.id == alarmaId);
final proxima = alarma.proximaEjecucion;
if (proxima == null) return config;
final excepciones = [
...config.excepciones,
ExcepcionAlarma(alarmaId: alarmaId, ejecucion: proxima, tipo: 'skipNext'),
];
final alarmas =
config.alarmas
.map(
(a) =>
a.id == alarmaId
? _recalcular(a, config.vacaciones, excepciones)
: a,
)
.toList();
final nuevo = ConfiguracionAlarmas(
alarmas: alarmas,
vacaciones: config.vacaciones,
excepciones: excepciones,
);
await _guardar(nuevo);
return nuevo;
}
final excepciones = [
...config.excepciones,
ExcepcionAlarma(
alarmaId: alarmaId,
ejecucion: proxima,
tipo: 'skipNext',
),
];
final alarmas =
config.alarmas
.map(
(a) =>
a.id == alarmaId
? _recalcular(a, config.vacaciones, excepciones)
: a,
)
.toList();
final nuevo = ConfiguracionAlarmas(
alarmas: alarmas,
vacaciones: config.vacaciones,
excepciones: excepciones,
);
await _guardar(nuevo);
return nuevo;
});
Future<ConfiguracionAlarmas> posponerEjecucion(
String alarmaId,
@@ -276,8 +322,8 @@ class ServicioAlarmas {
String alarmaId,
DateTime ejecucion,
DateTime snoozeHasta,
) async {
final config = await cargar();
) => _enCola(() async {
final config = await _configActual();
final ahora = _reloj();
final alarmas =
config.alarmas
@@ -300,13 +346,13 @@ class ServicioAlarmas {
);
await _guardar(nuevo);
return nuevo;
}
});
Future<ConfiguracionAlarmas> completarEjecucion(
String alarmaId,
DateTime ejecucion,
) async {
final config = await cargar();
) => _enCola(() async {
final config = await _configActual();
final ahora = _reloj();
final alarmas =
config.alarmas.map((a) {
@@ -336,7 +382,7 @@ class ServicioAlarmas {
);
await _guardar(nuevo);
return nuevo;
}
});
AlarmaMusical crearAlarma({
required String nombre,
@@ -372,18 +418,22 @@ class ServicioAlarmas {
);
}
Future<void> _guardar(ConfiguracionAlarmas config) async {
/// Persists [config] and refreshes the in-memory cache (the cache is
/// "invalidated" by replacing it with the just-written state).
Future<void> _guardar(ConfiguracionAlarmas config, {String? raw}) async {
final serializado = raw ?? _serializar(config);
final prefs = await _resolverPrefs();
await prefs.setString(
_keyConfig,
jsonEncode({
'alarmas': config.alarmas.map((a) => a.toJson()).toList(),
'vacaciones': config.vacaciones.map((v) => v.toJson()).toList(),
'excepciones': config.excepciones.map((e) => e.toJson()).toList(),
}),
);
await prefs.setString(_keyConfig, serializado);
_cache = config;
_cacheRaw = serializado;
}
String _serializar(ConfiguracionAlarmas config) => jsonEncode({
'alarmas': config.alarmas.map((a) => a.toJson()).toList(),
'vacaciones': config.vacaciones.map((v) => v.toJson()).toList(),
'excepciones': config.excepciones.map((e) => e.toJson()).toList(),
});
AlarmaMusical _recalcular(
AlarmaMusical alarma,
List<RangoVacaciones> vacaciones,