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
+21 -4
View File
@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'estado/estado_radio.dart';
import 'estado/estado_alarmas.dart';
import 'estado/estado_idioma.dart';
@@ -23,15 +24,21 @@ import 'package:pluriwave/widgets/mini_reproductor.dart';
import 'servicios/servicio_alarmas_android.dart';
class PluriWaveApp extends StatelessWidget {
const PluriWaveApp({super.key});
const PluriWaveApp({super.key, this.prefs});
/// Single SharedPreferences instance resolved in main() (S3-R4) and
/// injected into every state/service.
final SharedPreferences? prefs;
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => EstadoRadio()),
ChangeNotifierProvider(create: (_) => EstadoAlarmas()),
ChangeNotifierProvider(create: (_) => EstadoIdioma()),
ChangeNotifierProvider(create: (_) => EstadoRadio(prefs: prefs)),
ChangeNotifierProvider(create: (_) => EstadoAlarmas(prefs: prefs)),
ChangeNotifierProvider(
create: (_) => EstadoIdioma(sharedPreferences: prefs),
),
],
child: Consumer<EstadoIdioma>(
builder:
@@ -69,6 +76,7 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
bool _alarmaSonandoActiva = false;
bool _onboardingInicialSolicitado = false;
String? _alarmaSonandoId;
Locale? _localeAlarmasConfigurado;
static const _paginas = [
PantallaInicio(),
@@ -89,6 +97,15 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
@override
void didChangeDependencies() {
super.didChangeDependencies();
// S3-R3 / Decision 3.2: keep the alarm bridge l10n in sync, once per
// locale change (this hook re-runs when Localizations changes).
final locale = Localizations.localeOf(context);
if (_localeAlarmasConfigurado != locale) {
_localeAlarmasConfigurado = locale;
context.read<EstadoAlarmas>().configurarLocalizaciones(
AppLocalizations.of(context),
);
}
final estado = context.read<EstadoRadio>();
if (identical(_estadoSuscrito, estado) && _errorSubscription != null) {
return;
+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();
+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();
}
+14 -6
View File
@@ -4,8 +4,10 @@ import 'dart:ui' as ui;
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'app.dart';
import 'servicios/servicio_audio.dart';
import 'servicios/servicio_audio_session.dart';
const _anchoMinimoLandscape = 600.0;
@@ -13,6 +15,10 @@ Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await _aplicarPoliticaOrientacion();
// S3-R4: single SharedPreferences instance resolved once at startup and
// injected into every state/service below.
final prefs = await SharedPreferences.getInstance();
final handler = await AudioService.init(
builder: () => PluriWaveAudioHandler(),
config: const AudioServiceConfig(
@@ -25,7 +31,12 @@ Future<void> main() async {
);
registrarHandler(handler);
runApp(const _OrientacionResponsiveApp(child: PluriWaveApp()));
// S3-R1: audio focus phone calls / transient losses pause or duck the
// radio; headphones unplugged pauses it.
final sesionAudio = ServicioAudioSession(objetivo: handler);
unawaited(sesionAudio.configurar());
runApp(_OrientacionResponsiveApp(child: PluriWaveApp(prefs: prefs)));
}
Future<void> _aplicarPoliticaOrientacion([ui.Display? display]) async {
@@ -36,12 +47,9 @@ Future<void> _aplicarPoliticaOrientacion([ui.Display? display]) async {
final displayActivo = display ?? vista?.display;
if (displayActivo == null) return;
final anchoLogico =
displayActivo.size.width / displayActivo.devicePixelRatio;
final anchoLogico = displayActivo.size.width / displayActivo.devicePixelRatio;
if (anchoLogico < _anchoMinimoLandscape) {
await SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
]);
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
return;
}
+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,
+18 -11
View File
@@ -126,6 +126,10 @@ class EjecucionAlarmaNativa {
abstract class PuertoAlarmasAndroid {
Stream<EventoAlarmaAndroid> get eventosAlarma;
/// Provides the UI localizations used to localize the alarm/station names
/// sent to the native scheduler.
void configurarLocalizaciones(AppLocalizations l10n);
Future<void> programar(AlarmaMusical alarma);
Future<void> cancelar(String alarmaId);
Future<void> ocultarNotificacionAlarma(String alarmaId);
@@ -145,22 +149,24 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
ServicioAlarmasAndroid({
MethodChannel channel = const MethodChannel('pluriwave/alarm_scheduler'),
}) : _channel = channel {
_instalarHandler(_channel);
_instalarHandler();
}
final MethodChannel _channel;
static final _eventosController =
StreamController<EventoAlarmaAndroid>.broadcast();
static bool _handlerInstalado = false;
static AppLocalizations? _l10n;
static AppLocalizations get _textos {
// Instance state (S3-R2): each bridge owns its controller and l10n so
// independent instances never share events through globals.
final _eventosController = StreamController<EventoAlarmaAndroid>.broadcast();
AppLocalizations? _l10n;
AppLocalizations get _textos {
final actual = _l10n;
if (actual != null) return actual;
return lookupAppLocalizations(const Locale('es'));
}
static void configurarLocalizaciones(AppLocalizations l10n) {
@override
void configurarLocalizaciones(AppLocalizations l10n) {
_l10n = l10n;
}
@@ -334,10 +340,11 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
return _channel.invokeMethod<void>(method, args);
}
static void _instalarHandler(MethodChannel channel) {
if (_handlerInstalado) return;
_handlerInstalado = true;
channel.setMethodCallHandler((call) async {
// Installed once per instance from the constructor. Creating a second
// instance over the SAME channel re-binds the platform handler to the
// newest instance (production has exactly one instance per channel).
void _instalarHandler() {
_channel.setMethodCallHandler((call) async {
if (call.method != 'alarmFired') return;
final args = call.arguments;
if (args is Map) {
+50 -5
View File
@@ -9,6 +9,7 @@ import '../l10n/display_names.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/emisora.dart';
import '../modelos/preset_ecualizador.dart';
import 'servicio_audio_session.dart';
/// Estado de reproducción expuesto al UI.
enum EstadoReproduccion { detenido, cargando, reproduciendo, pausado, error }
@@ -110,9 +111,12 @@ class ServicioAudio {
//
// AudioHandler
//
class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
class PluriWaveAudioHandler extends BaseAudioHandler
with SeekHandler
implements ObjetivoAudioInterrumpible {
static const _timeoutCambioFuente = Duration(seconds: 12);
static const _timeoutCierrePlayer = Duration(seconds: 3);
static const _factorAtenuacion = 0.3;
AndroidEqualizer _eq = AndroidEqualizer();
late AudioPlayer _player = _crearPlayer();
@@ -130,6 +134,15 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
double get volumen => _volumen;
AppLocalizations? _l10n;
/// Intent-to-play flag (Designs 3.1/7.2): reflects the LAST explicit
/// intent (play/pause/stop, including audio-session interruptions, which
/// pause through [pausar]). The S7 reconnect state machine reads it to
/// distinguish a network stall from an intentional pause.
bool _intencionReproducir = false;
/// Ducked state requested by the audio session (transient focus loss).
bool _atenuado = false;
AndroidEqualizer? get ecualizador => _eq;
bool _eqDisponible = false;
bool get ecualizadorDisponible => _eqDisponible;
@@ -278,6 +291,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
@override
Future<void> playMediaItem(MediaItem mediaItem) async {
_intencionReproducir = true;
final revision = ++_revisionFuente;
_colaCambioFuente = _colaCambioFuente
.catchError((_) {})
@@ -349,7 +363,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
_eqDisponible = false;
_androidAudioSessionId = null;
_player = _crearPlayer();
await _player.setVolume(_volumen);
await _player.setVolume(_volumenEfectivo);
_conectarStreamsPlayer();
}
@@ -450,17 +464,48 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
Future<void> setVolumen(double vol) async {
_volumen = vol.clamp(0.0, 1.0);
await _player.setVolume(_volumen);
await _player.setVolume(_volumenEfectivo);
}
double get _volumenEfectivo =>
_atenuado ? _volumen * _factorAtenuacion : _volumen;
// ObjetivoAudioInterrumpible (audio-session seam, S3-R1)
@override
bool get intencionReproducir => _intencionReproducir;
@override
bool get estaReproduciendo => playbackState.value.playing;
@override
Future<void> pausar() => pause();
@override
Future<void> reanudar() => play();
@override
Future<void> setAtenuado(bool atenuado) async {
if (_atenuado == atenuado) return;
_atenuado = atenuado;
await _player.setVolume(_volumenEfectivo);
}
@override
Future<void> play() => _player.play();
Future<void> play() {
_intencionReproducir = true;
return _player.play();
}
@override
Future<void> pause() => _player.pause();
Future<void> pause() {
_intencionReproducir = false;
return _player.pause();
}
@override
Future<void> stop() async {
_intencionReproducir = false;
_revisionFuente++;
await _player.stop();
emisoraActual = null;
+112
View File
@@ -0,0 +1,112 @@
import 'dart:async';
import 'dart:developer' as developer;
import 'package:audio_session/audio_session.dart';
import 'package:flutter/foundation.dart';
/// Minimal playback contract this service needs from the audio handler
/// (Design 3.1). `PluriWaveAudioHandler` implements it; tests use a fake.
abstract class ObjetivoAudioInterrumpible {
/// Intent-to-play flag (Designs 3.1/7.2): true while the user wants audio
/// playing. The S7 reconnect logic reads the same flag, so an interruption
/// pause also disarms reconnection attempts.
bool get intencionReproducir;
bool get estaReproduciendo;
Future<void> pausar();
Future<void> reanudar();
/// Temporarily lowers ("ducks") the output volume without pausing.
Future<void> setAtenuado(bool atenuado);
}
/// Wrapper around `package:audio_session` (S3-R1): configures the session
/// for music playback and translates interruption / becoming-noisy events
/// into pause, duck and auto-resume calls on the audio handler.
class ServicioAudioSession {
ServicioAudioSession({
required ObjetivoAudioInterrumpible objetivo,
Future<AudioSession> Function()? obtenerSesion,
}) : _objetivo = objetivo,
_obtenerSesion = obtenerSesion ?? (() => AudioSession.instance);
final ObjetivoAudioInterrumpible _objetivo;
final Future<AudioSession> Function() _obtenerSesion;
StreamSubscription<AudioInterruptionEvent>? _interrupcionesSub;
StreamSubscription<void>? _ruidoSub;
/// True when WE paused because of an interruption; only then does an
/// interruption end with shouldResume restart playback.
bool _pausadoPorInterrupcion = false;
Future<void> configurar() async {
try {
final sesion = await _obtenerSesion();
await sesion.configure(
const AudioSessionConfiguration.music().copyWith(
androidWillPauseWhenDucked: true,
),
);
await _interrupcionesSub?.cancel();
await _ruidoSub?.cancel();
_interrupcionesSub = sesion.interruptionEventStream.listen(
(evento) => unawaited(manejarInterrupcion(evento)),
);
_ruidoSub = sesion.becomingNoisyEventStream.listen(
(_) => unawaited(manejarDesconexionSalida()),
);
} catch (e) {
developer.log(
'[PluriWave] No se pudo configurar la sesion de audio: $e',
name: 'ServicioAudioSession',
level: 900,
);
}
}
@visibleForTesting
Future<void> manejarInterrupcion(AudioInterruptionEvent evento) async {
if (evento.begin) {
switch (evento.type) {
case AudioInterruptionType.duck:
await _objetivo.setAtenuado(true);
case AudioInterruptionType.pause:
case AudioInterruptionType.unknown:
if (_objetivo.estaReproduciendo || _objetivo.intencionReproducir) {
_pausadoPorInterrupcion = true;
await _objetivo.pausar();
}
}
return;
}
switch (evento.type) {
case AudioInterruptionType.duck:
await _objetivo.setAtenuado(false);
case AudioInterruptionType.pause:
// Transient loss ended and the OS says we may resume.
if (_pausadoPorInterrupcion) {
_pausadoPorInterrupcion = false;
await _objetivo.reanudar();
}
case AudioInterruptionType.unknown:
// Permanent focus loss: never auto-resume.
_pausadoPorInterrupcion = false;
}
}
@visibleForTesting
Future<void> manejarDesconexionSalida() async {
// Headphones unplugged: hard pause, never auto-resume afterwards.
_pausadoPorInterrupcion = false;
if (_objetivo.estaReproduciendo) {
await _objetivo.pausar();
}
}
Future<void> dispose() async {
await _interrupcionesSub?.cancel();
await _ruidoSub?.cancel();
}
}
+11 -3
View File
@@ -27,12 +27,20 @@ class ContenidoAyudaPluri {
}
class ServicioContenidoApp {
ServicioContenidoApp({SharedPreferences? prefs}) : _prefs = prefs;
static const _keyOnboardingVisto = 'pluri_onboarding_visto_v1';
static const _keyVersionVista = 'pluri_ultima_version_novedades_v1';
static const _versiones = ['0.1.47'];
final SharedPreferences? _prefs;
/// Injected startup instance (S3-R4); getInstance() is only a fallback.
Future<SharedPreferences> _resolverPrefs() async =>
_prefs ?? SharedPreferences.getInstance();
Future<bool> debeMostrarInicio() async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _resolverPrefs();
final info = await PackageInfo.fromPlatform();
final versionActual = info.version;
return !(prefs.getBool(_keyOnboardingVisto) ?? false) ||
@@ -40,7 +48,7 @@ class ServicioContenidoApp {
}
Future<void> marcarVisto() async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _resolverPrefs();
final info = await PackageInfo.fromPlatform();
await prefs.setBool(_keyOnboardingVisto, true);
await prefs.setString(_keyVersionVista, info.version);
@@ -51,7 +59,7 @@ class ServicioContenidoApp {
bool soloPendientes = false,
}) async {
final info = await PackageInfo.fromPlatform();
final prefs = await SharedPreferences.getInstance();
final prefs = await _resolverPrefs();
final ultimaVista = prefs.getString(_keyVersionVista);
final idioma = _idiomaSoportado(codigoIdioma);
final mostrarOnboarding =
+14 -6
View File
@@ -17,12 +17,20 @@ class ConfiguracionEcualizador {
}
class ServicioEcualizador {
ServicioEcualizador({SharedPreferences? prefs}) : _prefs = prefs;
static const _keyPresetPrincipal = 'eq_preset_principal_v1';
static const _keyPresetsPorEmisora = 'eq_presets_por_emisora_v1';
static const _keyActivo = 'eq_activo_v1';
final SharedPreferences? _prefs;
/// Injected startup instance (S3-R4); getInstance() is only a fallback.
Future<SharedPreferences> _resolverPrefs() async =>
_prefs ?? SharedPreferences.getInstance();
Future<ConfiguracionEcualizador> cargar() async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _resolverPrefs();
final principal = _leerPresetPrincipal(prefs);
final porEmisora = _leerPresetsPorEmisora(prefs);
return ConfiguracionEcualizador(
@@ -33,31 +41,31 @@ class ServicioEcualizador {
}
Future<void> guardarPrincipal(PresetEcualizador preset) async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _resolverPrefs();
await prefs.setString(_keyPresetPrincipal, jsonEncode(preset.toJson()));
}
Future<void> guardarPorEmisora(String uuid, PresetEcualizador preset) async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _resolverPrefs();
final mapa = _leerPresetsPorEmisora(prefs);
mapa[uuid] = preset;
await _guardarPresetsPorEmisora(prefs, mapa);
}
Future<void> guardarActivo(bool activo) async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _resolverPrefs();
await prefs.setBool(_keyActivo, activo);
}
Future<void> eliminarPorEmisora(String uuid) async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _resolverPrefs();
final mapa = _leerPresetsPorEmisora(prefs);
mapa.remove(uuid);
await _guardarPresetsPorEmisora(prefs, mapa);
}
Future<void> guardarConfiguracion(ConfiguracionEcualizador config) async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _resolverPrefs();
await prefs.setString(
_keyPresetPrincipal,
jsonEncode(config.principal.toJson()),
+12 -5
View File
@@ -82,9 +82,11 @@ class ServicioGrabacionRadio {
http.Client? cliente,
Future<Directory> Function()? resolverDirectorioBase,
DateTime Function()? reloj,
SharedPreferences? prefs,
}) : _clienteExterno = cliente,
_resolverDirectorioBase = resolverDirectorioBase,
_reloj = reloj ?? DateTime.now;
_reloj = reloj ?? DateTime.now,
_prefs = prefs;
static const _claveDirectorio = 'grabacion_radio_directorio';
static const _claveMaxBytes = 'grabacion_radio_max_bytes_v1';
@@ -93,6 +95,11 @@ class ServicioGrabacionRadio {
final http.Client? _clienteExterno;
final Future<Directory> Function()? _resolverDirectorioBase;
final DateTime Function() _reloj;
final SharedPreferences? _prefs;
/// Injected startup instance (S3-R4); getInstance() is only a fallback.
Future<SharedPreferences> _resolverPrefs() async =>
_prefs ?? SharedPreferences.getInstance();
final _estadoController = StreamController<EstadoGrabacionRadio>.broadcast();
AppLocalizations? _l10n;
@@ -123,7 +130,7 @@ class ServicioGrabacionRadio {
Future<void> inicializar() async {
try {
final prefs = await SharedPreferences.getInstance();
final prefs = await _resolverPrefs();
_directorioConfigurado = prefs.getString(_claveDirectorio);
_maxBytes = prefs.getInt(_claveMaxBytes) ?? maxBytesPorDefecto;
} catch (_) {
@@ -151,7 +158,7 @@ class ServicioGrabacionRadio {
}
_directorioConfigurado = normalizado;
try {
final prefs = await SharedPreferences.getInstance();
final prefs = await _resolverPrefs();
await prefs.setString(_claveDirectorio, normalizado);
} catch (_) {}
_emitir(_estado);
@@ -160,7 +167,7 @@ class ServicioGrabacionRadio {
Future<void> limpiarDirectorioConfigurado() async {
_directorioConfigurado = null;
try {
final prefs = await SharedPreferences.getInstance();
final prefs = await _resolverPrefs();
await prefs.remove(_claveDirectorio);
} catch (_) {}
_emitir(_estado);
@@ -172,7 +179,7 @@ class ServicioGrabacionRadio {
}
_maxBytes = bytes;
try {
final prefs = await SharedPreferences.getInstance();
final prefs = await _resolverPrefs();
await prefs.setInt(_claveMaxBytes, bytes);
} catch (_) {}
_emitir(_estado);
+22 -2
View File
@@ -13,14 +13,34 @@ import 'visualizador_audio.dart';
/// Barra inferior persistente con controles básicos de reproducción.
/// Toca la barra para abrir PantallaReproductor completa.
class MiniReproductor extends StatelessWidget {
class MiniReproductor extends StatefulWidget {
const MiniReproductor({super.key});
@override
State<MiniReproductor> createState() => _MiniReproductorState();
}
class _MiniReproductorState extends State<MiniReproductor> {
Locale? _localeConfigurado;
@override
void didChangeDependencies() {
super.didChangeDependencies();
// S3-R3: configure localizations once per locale change never from
// build(), which re-runs on every playback notification.
final locale = Localizations.localeOf(context);
if (_localeConfigurado != locale) {
_localeConfigurado = locale;
context.read<EstadoRadio>().configurarLocalizaciones(
AppLocalizations.of(context),
);
}
}
@override
Widget build(BuildContext context) {
final estado = context.watch<EstadoRadio>();
final l10n = AppLocalizations.of(context);
estado.configurarLocalizaciones(l10n);
final emisora = estado.emisoraActual;
if (emisora == null) return const SizedBox.shrink();
@@ -3,7 +3,7 @@
**Mode**: Strict TDD (test runner: `flutter test`)
**Artifact store**: openspec (Engram unavailable this session)
**Delivery**: auto-chain, local apply — no commits, no PRs (user commits at own cadence)
**Last updated**: 2026-06-11 (Batch 2)
**Last updated**: 2026-06-11 (Batch 3)
## Batch log
@@ -11,6 +11,7 @@
|-------|-------|--------|------|
| 1 | S1 — Alarm native reliability | COMPLETE (Dart verified; Kotlin/manifest on-device verification deferred to user) | 2026-06-11 |
| 2 | S2a + S2b — Snooze correctness end-to-end + Alarm UX parity | COMPLETE (Dart verified; Kotlin on-device verification deferred to user) | 2026-06-11 |
| 3 | S3a + S3b — Test seams (statics/prefs/cache/mutex/dirty-guard/bounded set) + audio_session | COMPLETE (Dart-only batch; call-pause on-device verification deferred to user) | 2026-06-11 |
## Task status (cumulative)
@@ -78,9 +79,41 @@
| T-S2b-11 | [x] | `flutter analyze` — No issues found |
| T-S2b-12 | [x] | `dart format` applied |
### Slice S3a — Test seams — 15/15 complete
| Task | Status | Notes |
|------|--------|-------|
| T-S3a-01 | [x] | RED: `servicio_alarmas_android_instance_test.dart` — two channels, simulated `alarmFired` via `handlePlatformMessage`, isolation asserted both ways |
| T-S3a-02 | [x] | RED: `servicio_alarmas_cache_test.dart``_PrefsEspia implements SharedPreferences` (setString/getString counters); no-write-when-clean, exactly-one-write-when-dirty, concurrent-no-lost-write (+ single cache hydration) |
| T-S3a-03 | [x] | RED: `estado_alarmas_ejecuciones_test.dart` — 100 stale entries pruned (1 fresh survives) + 250-entry cap test |
| T-S3a-04 | [x] | RED: `mini_reproductor_configurar_test.dart` — 10 rebuilds → 1 configurar; locale es→en → 2 |
| T-S3a-05 | [x] | GREEN: `ServicioAlarmasAndroid` statics → instance fields; handler installed per instance in ctor. **DEVIATION:** no deprecated static shim (Dart name clash + only call site rewired in same change); `configurarLocalizaciones` added to `PuertoAlarmasAndroid` interface instead |
| T-S3a-06 | [x] | GREEN: `MiniReproductor` → StatefulWidget, locale-guarded `didChangeDependencies`; alarm-bridge l10n hoisted to `app.dart` `_PaginaPrincipalState.didChangeDependencies` (design 3.3 alternative), before the early-return |
| T-S3a-07 | [x] | GREEN: `main.dart` resolves prefs ONCE; `PluriWaveApp(prefs:)``EstadoRadio`/`EstadoAlarmas`(→`ServicioAlarmas`)/`EstadoIdioma` |
| T-S3a-08 | [x] | GREEN: injected-with-fallback `_resolverPrefs()` in `estado_radio` (10 sites), `servicio_ecualizador` (6), `servicio_grabacion_radio` (4), `servicio_contenido_app` (3). rg check: only main.dart + one fallback per class remain |
| T-S3a-09 | [x] | GREEN: `recalcularTodas` dirty-guard — serialized comparison vs `_cacheRaw`, skips write when identical |
| T-S3a-10 | [x] | GREEN: `_cache`/`_cacheRaw` + `_enCola` writer queue; all 8 mutation methods queued over `_configActual()`. **DEVIATION:** public `cargar()` still re-reads prefs (queued cache reset) because `EstadoRadio.importarConfig` writes the raw alarms key directly — a fully cached cargar would hide imports until restart |
| T-S3a-11 | [x] | GREEN: bounded `_ejecucionesEmitidas` — cap 200 + 24 h retention, pruned on every add and each `_vigilarAlarmasVencidas` pass; `@visibleForTesting` length getter |
| T-S3a-12 | [x] | Targeted S3a tests green (RED first: 1 passed / 6 failed across the batch) |
| T-S3a-13 | [x] | Full suite 89/89 (77 baseline + 12 new) |
| T-S3a-14 | [x] | `flutter analyze` — No issues found |
| T-S3a-15 | [x] | `dart format` on 19 touched files |
### Slice S3b — audio_session + intent flag — 7/7 complete
| Task | Status | Notes |
|------|--------|-------|
| T-S3b-01 | [x] | RED: `servicio_audio_session_test.dart` — 5 tests (pause-begin, resume-end, no-resume-without-prior-pause, duck begin/end, becoming-noisy hard pause) over fake `ObjetivoAudioInterrumpible` |
| T-S3b-02 | [x] | GREEN: `lib/servicios/servicio_audio_session.dart``music().copyWith(androidWillPauseWhenDucked: true)`; interruption + becoming-noisy subscriptions; `_pausadoPorInterrupcion` gate for auto-resume; defines `ObjetivoAudioInterrumpible` (test seam) |
| T-S3b-03 | [x] | GREEN: `PluriWaveAudioHandler implements ObjetivoAudioInterrumpible`; `_intencionReproducir` true in `play()`/`playMediaItem()`, false in `pause()`/`stop()` (S7 seam); duck via `setAtenuado` ×0.3 (`_volumenEfectivo`); `configurar()` wired in `main.dart` |
| T-S3b-04 | [x] | Targeted run 5/5 green (RED first: load failure) |
| T-S3b-05 | [x] | Full suite 89/89 |
| T-S3b-06 | [x] | `flutter analyze` — No issues found |
| T-S3b-07 | [x] | `dart format` applied |
### Remaining slices (not started)
S3a, S3b, S7, S4a, S4b, S5, S6, cross-cutting (T-CC-01, T-CC-02) — all pending.
S7, S4a, S4b, S5, S6, cross-cutting (T-CC-01, T-CC-02) — all pending.
## Snooze defect fixes (design audit D1D5 / S1S5)
@@ -106,6 +139,20 @@ S3a, S3b, S7, S4a, S4b, S5, S6, cross-cutting (T-CC-01, T-CC-02) — all pending
RED run evidence: first run `+0 -3` (loading failures, three files); ringing tap test `Expected: DateTime:<07:35> Actual: <null>`; S2b run `+0 -7` before implementation. GREEN: targeted 23/23 then 7/7; full suite `00:24 +77: All tests passed!`.
### Batch 3 TDD Cycle Evidence (S3a + S3b)
| Task | RED (test written first, failing) | GREEN (implementation passes) | REFACTOR |
|------|-----------------------------------|-------------------------------|----------|
| T-S3a-01/T-S3a-05 | Static controller shared events: `expect(eventosB, isEmpty)` FAILED ('solo-a' leaked into B) | Statics → instance fields; isolation test passes | Comment documenting handler re-bind semantics |
| T-S3a-02-A/T-S3a-09 | `recalcularTodas` always wrote: `Expected: <1> Actual: <2>` writes | Dirty-guard skips clean writes | `_serializar` extracted, shared with `_guardar` |
| T-S3a-02-B | Passed pre-fix (exactly-once lock-in guard) | Still passes (regression lock) | — |
| T-S3a-02-C/T-S3a-10 | Lost write: final config had 1 of 2 alarms; 2 hydration reads | Cache + `_enCola` queue: both alarms persisted, 1 hydration read | Mutation bodies kept verbatim, only wrapped |
| T-S3a-03/T-S3a-11 | Load failure: `ejecucionesEmitidasLength`/`maxEjecucionesEmitidas` undefined | Bounded set: 1 survivor of 101, cap respected | Prune helper shared by add-path and watch-pass |
| T-S3a-04/T-S3a-06 | `Expected: <1> Actual: <11>` (configurar on every rebuild) | StatefulWidget + locale guard: 1 then 2 | FakeServicioAudio gained l10n override (assert fix) |
| T-S3b-01/02/03 | Load failure: `servicio_audio_session.dart` missing | 5/5 green against fake objetivo | — |
RED run evidence (Batch 3): `00:06 +1 -6` before implementation (the single pass is the exactly-once write lock-in). GREEN: targeted 12/12; full suite `00:12 +89: All tests passed!`.
## Files changed (Batch 2)
| File | Action | ~Lines |
@@ -132,6 +179,42 @@ RED run evidence: first run `+0 -3` (loading failures, three files); ringing tap
| `test/pantallas/pantalla_alarmas_editor_test.dart` | Created | +210 |
| `test/estado/estado_alarmas_test.dart` | Modified | -78/+8 (fake deduplicated to helper; anchor expectations 7:36:00 → 7:36:02) |
## Files changed (Batch 3)
| File | Action | ~Lines |
|------|--------|--------|
| `lib/servicios/servicio_audio_session.dart` | Created | +115 (session config, interruption/noisy handling, `ObjetivoAudioInterrumpible`) |
| `lib/servicios/servicio_alarmas.dart` | Modified | +106/-64 (cache, `_enCola` queue, dirty-guard, `_parsear`/`_serializar`) |
| `lib/estado/estado_alarmas.dart` | Modified | +55/-2 (bounded set, `configurarLocalizaciones`, prefs→servicio default) |
| `lib/servicios/servicio_alarmas_android.dart` | Modified | +18/-11 (statics → instance, interface method) |
| `lib/servicios/servicio_audio.dart` | Modified | +52/-3 (intent flag, `ObjetivoAudioInterrumpible` impl, duck volume) |
| `lib/estado/estado_radio.dart` | Modified | +16/-15 (prefs param + `_resolverPrefs`, static alarm-l10n call removed) |
| `lib/widgets/mini_reproductor.dart` | Modified | +22/-2 (StatefulWidget, locale-guarded didChangeDependencies) |
| `lib/main.dart` | Modified | +14/-2 (prefs once, ServicioAudioSession wiring) |
| `lib/app.dart` | Modified | +22/-3 (PluriWaveApp.prefs, alarm l10n locale guard) |
| `lib/servicios/servicio_ecualizador.dart` | Modified | +14/-6 (prefs injection) |
| `lib/servicios/servicio_grabacion_radio.dart` | Modified | +13/-4 (prefs injection) |
| `lib/servicios/servicio_contenido_app.dart` | Modified | +11/-3 (prefs injection) |
| `test/helpers/fakes.dart` | Modified | +8 (`configurarLocalizaciones` override on FakeServicioAudio) |
| `test/helpers/fakes_alarmas.dart` | Modified | +4 (interface no-op) |
| `test/servicios/servicio_alarmas_android_instance_test.dart` | Created | +53 |
| `test/servicios/servicio_alarmas_cache_test.dart` | Created | +105 |
| `test/estado/estado_alarmas_ejecuciones_test.dart` | Created | +85 |
| `test/widgets/mini_reproductor_configurar_test.dart` | Created | +85 |
| `test/servicios/servicio_audio_session_test.dart` | Created | +130 |
Total Batch 3 diff: ~362 insertions / ~122 deletions in lib + helpers, plus ~458 lines of new tests.
## Deviations from design (Batch 3)
1. **No deprecated static shim for `ServicioAlarmasAndroid.configurarLocalizaciones`** (task text asked for one). Dart forbids a static and an instance member with the same name in one class, and the ONLY external call site (`estado_radio.dart:74`) was rewired in this same slice — a renamed shim would be unreachable dead code. Instead `configurarLocalizaciones` joined the `PuertoAlarmasAndroid` interface (fakes no-op it).
2. **Alarm-bridge l10n configured from `app.dart`, not from `MiniReproductor`** — design 3.3 offered both options; the mini player now only configures its real dependency (`EstadoRadio`), and `_PaginaPrincipalState.didChangeDependencies` (locale-guarded, placed BEFORE the existing early-return) forwards l10n to `EstadoAlarmas`.
3. **Public `ServicioAlarmas.cargar()` re-reads from prefs instead of serving the cache.** `EstadoRadio.importarConfig` writes the raw `alarmas_musicales_v1` key directly to SharedPreferences; a fully cached `cargar()` would make a settings import invisible until app restart. Mutations DO use the cache (`_configActual`), which is what S3-R7's race fix and "one cargar per mutation burst" require. The re-read is queued, so it cannot interleave with a mutation.
4. **Duck handling added beyond the task text**: `setAtenuado` on the handler scales effective volume ×0.3 (restored on interruption end). With `androidWillPauseWhenDucked: true` Android delivers duck as pause, so this is mostly the iOS/edge path; kept minimal.
5. **`_PrefsEspia` implements SharedPreferences via noSuchMethod** rather than pulling `shared_preferences_platform_interface` into the tests — avoids a `depend_on_referenced_packages` lint on a transitive dep.
6. **`servicio_contenido_app.dart` also migrated** (3 getInstance sites; not named in the task). Its only construction site is `static final` in `pluri_onboarding_dialog.dart`, which keeps the fallback path at runtime — acceptable under the injected-with-fallback compat net; full injection there would require a dialog refactor out of S3 scope.
7. **Two-instances-same-channel semantics documented, not prevented**: with instance handlers, constructing a second `ServicioAlarmasAndroid` over the SAME MethodChannel re-binds the platform handler to the newest instance. Production creates exactly one per channel (provider singleton); tests use distinct channels.
## Deviations from design (Batch 2)
1. **Event subscription lives in the `EstadoAlarmas` CONSTRUCTOR**, not `inicializar` (task text said inicializar). Rationale: snoozed events must be recorded even before/without full init, and tests with `iniciarAutomaticamente: false` stay light. Cancelled in `dispose`.
@@ -171,9 +254,26 @@ From tasks.md Section 11 — S1 items still pending from Batch 1, plus new S2 it
- `flutter gen-l10n`: run once after .arb edits
- `flutter build`: NOT run (forbidden)
## Verification summary (Batch 3)
- `flutter test`: 89/89 passing (77 baseline + 12 new across 5 files)
- `flutter analyze`: No issues found (identical to baseline)
- `dart format`: applied to all 19 touched Dart files (5 reflowed)
- `rg 'SharedPreferences.getInstance()' lib/`: only `main.dart` startup resolution + one injected-with-fallback expression per class (estado_alarmas, estado_idioma, estado_radio, servicio_alarmas, servicio_ecualizador, servicio_grabacion_radio, servicio_contenido_app)
- `flutter build`: NOT run (forbidden)
- No Kotlin/native files touched in this batch
### On-device verification items added by Batch 3 (user — Android device)
1. **Phone call pauses radio (S3-R1, checklist item 10):** while the radio plays, receive a call → radio pauses (or ducks); after the call ends it resumes automatically (transient loss).
2. **Headphones unplugged pauses radio (S3-R1):** unplug wired headphones / disconnect BT while playing → radio pauses and does NOT auto-resume.
3. **Another media app takes focus:** start playback in another app → PluriWave pauses; it must not resume on its own when focus is permanent loss.
4. **Locale switch sanity:** change app language in Ajustes → alarm titles/station names sent to new native schedules use the new language (l10n now configured per locale change, not per rebuild).
5. **Settings import still reflects alarms immediately** (cache bypass in `cargar()`): import a backup with alarms → the alarms list shows them without restarting the app.
## Workload / boundary
- Mode: auto-chain local slices (no PRs)
- Current work units: S2a + S2b (complete)
- Boundary: starts from S1-complete tree; ends with S2a+S2b fully checked off, suite green. Rollback = revert the Batch-2 files listed above (S1 files only touched additively in `AlarmScheduler.kt`/`MainActivity.kt`/`PluriWaveAlarmService.kt`).
- Next batch: S3a (test seams) — prerequisite: user performs on-device verification for S1+S2 Kotlin, especially compile.
- Current work units: S1, S2a, S2b (committed f3e9487), S3a + S3b (complete, in working tree)
- Boundary (Batch 3): starts from the clean post-f3e9487 tree; ends with S3a+S3b fully checked off, suite green. Rollback = revert the Batch-3 files listed above (Dart-only; no native edits).
- Next batch: S7 (streaming resilience) — depends on the `_intencionReproducir` seam and `ObjetivoAudioInterrumpible` landed here. No on-device prerequisite for S7 implementation, but items 1-2 above validate the seam S7 builds on.
@@ -182,30 +182,30 @@ Chain strategy: N/A (local apply)
### S3a pre-work: write failing tests
- [ ] **T-S3a-01** [RED] Create `test/servicios/servicio_alarmas_android_instance_test.dart`: two `ServicioAlarmasAndroid` instances do not share `_eventosController` (S3-R2-A). Use a fake `MethodChannel`. **~20 lines.**
- [ ] **T-S3a-02** [RED] Create `test/servicios/servicio_alarmas_cache_test.dart`:
- [x] **T-S3a-01** [RED] Create `test/servicios/servicio_alarmas_android_instance_test.dart`: two `ServicioAlarmasAndroid` instances do not share `_eventosController` (S3-R2-A). Use a fake `MethodChannel`. **DONE — two distinct channels, simulated `alarmFired` via `handlePlatformMessage`, both directions asserted.**
- [x] **T-S3a-02** [RED] Create `test/servicios/servicio_alarmas_cache_test.dart`:
- Test A: `recalcularTodas` does NOT call `SharedPreferences.setString` when schedule unchanged (S3-R5-A).
- Test B: `recalcularTodas` calls `SharedPreferences.setString` exactly once when changed (S3-R5-B).
- Test C: Two concurrent `guardarAlarma` calls produce consistent final state — no lost write (S3-R7-A, S6-R2 test #1 preview). **~50 lines.**
- [ ] **T-S3a-03** [RED] Create `test/estado/estado_alarmas_ejecuciones_test.dart`: `_ejecucionesEmitidas` with 100 stale entries triggers pruning; length ≤ max threshold after prune (S3-R6-A). **~20 lines.**
- [ ] **T-S3a-04** [RED] Create `test/widgets/mini_reproductor_configurar_test.dart`: `configurarLocalizaciones` called at most once per locale change across 10 rebuilds (S3-R3-A). **~20 lines.**
- Test C: Two concurrent `guardarAlarma` calls produce consistent final state — no lost write (S3-R7-A, S6-R2 test #1 preview). **DONE — `_PrefsEspia implements SharedPreferences` (counts setString/getString); Test C also asserts the mutations hydrate the cache at most once.**
- [x] **T-S3a-03** [RED] Create `test/estado/estado_alarmas_ejecuciones_test.dart`: `_ejecucionesEmitidas` with 100 stale entries triggers pruning; length ≤ max threshold after prune (S3-R6-A). **DONE — plus a cap test (250 fresh entries → ≤ 200).**
- [x] **T-S3a-04** [RED] Create `test/widgets/mini_reproductor_configurar_test.dart`: `configurarLocalizaciones` called at most once per locale change across 10 rebuilds (S3-R3-A). **DONE — counter subclass of `EstadoRadio`; 10 notifyListeners rebuilds → 1 call; locale es→en → 2nd call.**
### S3a implementation
- [ ] **T-S3a-05** [GREEN] Edit `lib/servicios/servicio_alarmas_android.dart` (lines 117-120): convert `static _eventosController`, `static _handlerInstalado`, `static _l10n` to INSTANCE fields. Install handler in constructor. Add deprecated static shim for `estado_radio.dart:74` call site (one-release compat). Rewire `EstadoRadio.configurarLocalizaciones` to call the instance. **Reqs:** S3-R2. **~40 lines.**
- [ ] **T-S3a-06** [GREEN] Edit `lib/widgets/mini_reproductor.dart` (line 23): convert to `StatefulWidget` if not already; move `configurarLocalizaciones(l10n)` call to `didChangeDependencies`, guarded by a cached `Locale` comparison so it only runs on locale change. **Reqs:** S3-R3. **~25 lines.**
- [ ] **T-S3a-07** [GREEN] Edit `lib/main.dart`: resolve `SharedPreferences.getInstance()` ONCE before `runApp`; pass the instance through to providers / service constructors. **Reqs:** S3-R4. **~10 lines.**
- [ ] **T-S3a-08** [GREEN] Audit and edit `lib/servicios/servicio_ecualizador.dart`, `lib/servicios/servicio_grabacion_radio.dart`, and any remaining service calling `SharedPreferences.getInstance()` inline (~25 sites): replace with injected `prefs` parameter. Use `_resolverPrefs` fallback in `servicio_alarmas.dart:399-400` as temporary compat net during migration. **Reqs:** S3-R4. **~30 lines total across files.**
- [ ] **T-S3a-09** [GREEN] Edit `lib/servicios/servicio_alarmas.dart` (lines 316-323): add dirty-check in `recalcularTodas` — serialize new config; compare to loaded serialized; skip `_guardar` if identical. Return loaded config unchanged when clean. **Reqs:** S3-R5. **~20 lines.**
- [ ] **T-S3a-10** [GREEN] Edit `lib/servicios/servicio_alarmas.dart` (lines 81-108): introduce in-memory `ConfiguracionAlarmas?` cache and a `Future`-chain mutex (mirror `_colaCambioFuente` pattern from `servicio_audio.dart:125`). All mutations: `await _lock` → read cache → mutate → persist → update cache → release. Remove `cargar()` calls before each mutation. **Reqs:** S3-R7. **~50 lines.**
- [ ] **T-S3a-11** [GREEN] Edit `lib/estado/estado_alarmas.dart` (line 32): replace unbounded `Set<String> _ejecucionesEmitidas` with a bounded structure (cap ~200 entries); add pruning of entries with millis suffix older than 24 h on each `_vigilarAlarmasVencidas` pass (lines 326-348). **Reqs:** S3-R6. **~25 lines.**
- [x] **T-S3a-05** [GREEN] Edit `lib/servicios/servicio_alarmas_android.dart` (lines 117-120): convert `static _eventosController`, `static _handlerInstalado`, `static _l10n` to INSTANCE fields. Install handler in constructor. ~~Add deprecated static shim~~ **DEVIATION:** Dart forbids a static and an instance member with the same name; the ONLY call site (`estado_radio.dart:74`) was rewired in this same change, so no shim exists. `configurarLocalizaciones` was added to the `PuertoAlarmasAndroid` INTERFACE; `EstadoAlarmas.configurarLocalizaciones` forwards to its bridge; `app.dart` configures it once per locale change. **Reqs:** S3-R2. **DONE.**
- [x] **T-S3a-06** [GREEN] `MiniReproductor` converted to `StatefulWidget`; `configurarLocalizaciones` moved to `didChangeDependencies` guarded by cached `Locale`. Alarm-bridge l10n hoisted to `app.dart` `_PaginaPrincipalState.didChangeDependencies` (design 3.3 alternative), also locale-guarded and placed BEFORE the early-return. **Reqs:** S3-R3. **DONE.**
- [x] **T-S3a-07** [GREEN] `main.dart` resolves `SharedPreferences.getInstance()` ONCE before `runApp`; `PluriWaveApp(prefs:)` injects it into `EstadoRadio`, `EstadoAlarmas` (→ default `ServicioAlarmas(prefs:)`), and `EstadoIdioma`. **Reqs:** S3-R4. **DONE.**
- [x] **T-S3a-08** [GREEN] All inline `getInstance()` sites migrated to injected-with-fallback `_resolverPrefs()`: `estado_radio.dart` (10 sites), `servicio_ecualizador.dart` (6), `servicio_grabacion_radio.dart` (4), `servicio_contenido_app.dart` (3). `rg 'SharedPreferences.getInstance()'` in lib/ now shows ONLY main.dart plus one fallback expression per class. **Reqs:** S3-R4. **DONE.**
- [x] **T-S3a-09** [GREEN] Dirty-guard in `recalcularTodas` (servicio_alarmas.dart:189-207): serializes the recalculated config and compares against the cached raw; skips `_guardar` and returns the loaded config when identical. **Reqs:** S3-R5. **DONE.**
- [x] **T-S3a-10** [GREEN] In-memory `ConfiguracionAlarmas? _cache` + `_cacheRaw` + `_enCola` Future-chain writer queue (mirrors `_colaCambioFuente`). ALL mutations (`guardarAlarma`, `eliminarAlarma`, `guardarVacaciones`, `recalcularTodas`, `sincronizarEjecucionesNativas`, `saltarProxima`, `posponerEjecucionHasta`, `completarEjecucion`) run queued over `_configActual()` (cache-or-hydrate). **DEVIATION (intentional):** public `cargar()` still re-reads from prefs (cache reset inside the queue) because `EstadoRadio.importarConfig` writes the raw alarms key DIRECTLY to prefs — a fully cached `cargar()` would make imports invisible until restart. **Reqs:** S3-R7. **DONE.**
- [x] **T-S3a-11** [GREEN] `_ejecucionesEmitidas` bounded: `maxEjecucionesEmitidas = 200` cap with oldest-millis eviction + 24 h retention prune (`_depurarEjecucionesEmitidas`), run on every add (`_registrarEjecucionEmitida`) and at the start of each `_vigilarAlarmasVencidas` pass. `@visibleForTesting` length getter. **Reqs:** S3-R6. **DONE.**
### S3a verification
- [ ] **T-S3a-12** Run `flutter test test/servicios/servicio_alarmas_android_instance_test.dart test/servicios/servicio_alarmas_cache_test.dart test/estado/estado_alarmas_ejecuciones_test.dart test/widgets/mini_reproductor_configurar_test.dart`.
- [ ] **T-S3a-13** Run `flutter test` (full suite) — no regressions.
- [ ] **T-S3a-14** Run `flutter analyze`zero errors.
- [ ] **T-S3a-15** Run `dart format` on all edited Dart files.
- [x] **T-S3a-12** Run `flutter test` on the four new S3a files — green (RED captured first: 1 passed / 6 failed across the batch).
- [x] **T-S3a-13** Run `flutter test` (full suite) — 89/89 passing (77 baseline + 12 new), no regressions.
- [x] **T-S3a-14** Run `flutter analyze``No issues found!`.
- [x] **T-S3a-15** Run `dart format` on all edited Dart files (19 files, 5 reflowed).
### S3a Definition of Done
- `flutter test` green.
@@ -221,23 +221,23 @@ Chain strategy: N/A (local apply)
### S3b pre-work: write failing tests
- [ ] **T-S3b-01** [RED] Create `test/servicios/servicio_audio_session_test.dart`:
- [x] **T-S3b-01** [RED] Create `test/servicios/servicio_audio_session_test.dart`:
- Test A: interruption `begin/pause` event sets `_intencionReproducir` to false and pauses playback. (S3-R1)
- Test B: interruption `end/shouldResume` resumes playback. (S3-R1)
- Test C: becoming-noisy event pauses playback. (S3-R1)
**~30 lines.**
**DONE — 5 tests (also: end without prior interruption-pause does NOT resume; duck begin/end attenuates and restores) against a fake `ObjetivoAudioInterrumpible`.**
### S3b implementation
- [ ] **T-S3b-02** [GREEN] Create `lib/servicios/servicio_audio_session.dart`: `ServicioAudioSession` wrapper around `package:audio_session`. In `configurar()`: `AudioSession.instance` configure with `AudioSessionConfiguration.music()` adjusted (playback category, `androidWillPauseWhenDucked: true`). Subscribe to `interruptionEventStream` (pause/duck/resume) and `becomingNoisyEventStream` (pause). On interrupt begin: call `handler.pause()` + set `handler._intencionReproducir = false`. On end with `shouldResume`: call `handler.play()` + set `handler._intencionReproducir = true`. **Reqs:** S3-R1. **~60 lines.**
- [ ] **T-S3b-03** [GREEN] Edit `lib/servicios/servicio_audio.dart` `PluriWaveAudioHandler`: expose `_intencionReproducir` flag (bool, default false). Set true in `play()`/`reproducir()`/`reanudar()`; set false in `pause()`/`detener()`. This is the seam S7 will read. Wire `ServicioAudioSession.configurar()` call from `main.dart` or `PluriWaveAudioHandler` init. **Reqs:** S3-R1. **~20 lines.**
- [x] **T-S3b-02** [GREEN] `lib/servicios/servicio_audio_session.dart` created: `ServicioAudioSession` configures `AudioSessionConfiguration.music().copyWith(androidWillPauseWhenDucked: true)`, subscribes to `interruptionEventStream` + `becomingNoisyEventStream`. Pause-type begin → pause (remembers `_pausadoPorInterrupcion`); end/pause-type → resume ONLY if we paused; end/unknown → never resume; duck begin/end → `setAtenuado(true/false)`; noisy → hard pause, clears the resume flag. Also defines the `ObjetivoAudioInterrumpible` interface (test seam). **Reqs:** S3-R1. **DONE.**
- [x] **T-S3b-03** [GREEN] `PluriWaveAudioHandler implements ObjetivoAudioInterrumpible`: `_intencionReproducir` set true in `play()`/`playMediaItem()` (covers `reproducir`/`reanudar`), false in `pause()`/`stop()` (covers `detener`; interruption pauses route through `pausar()``pause()`). Duck = `setAtenuado` scaling effective volume by 0.3 (`_volumenEfectivo`, respected by `setVolumen` and `_recrearPlayer`). `ServicioAudioSession.configurar()` wired in `main.dart` after `registrarHandler`. This is the seam S7 reads. **Reqs:** S3-R1. **DONE.**
### S3b verification
- [ ] **T-S3b-04** Run `flutter test test/servicios/servicio_audio_session_test.dart`.
- [ ] **T-S3b-05** Run `flutter test` (full suite) — no regressions.
- [ ] **T-S3b-06** Run `flutter analyze`zero errors.
- [ ] **T-S3b-07** Run `dart format lib/servicios/servicio_audio_session.dart lib/servicios/servicio_audio.dart`.
- [x] **T-S3b-04** Run `flutter test test/servicios/servicio_audio_session_test.dart` — 5/5 green (RED captured first: load failure, file missing).
- [x] **T-S3b-05** Run `flutter test` (full suite) — 89/89, no regressions.
- [x] **T-S3b-06** Run `flutter analyze``No issues found!`.
- [x] **T-S3b-07** Run `dart format lib/servicios/servicio_audio_session.dart lib/servicios/servicio_audio.dart` — applied (included in the 19-file format pass).
### S3b Definition of Done
- `flutter test` green.
@@ -0,0 +1,82 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_alarmas.dart';
import 'package:pluriwave/modelos/alarma_musical.dart';
import 'package:pluriwave/servicios/servicio_alarmas.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/fakes_alarmas.dart';
/// S3-R6: `_ejecucionesEmitidas` must be bounded stale entries (>24 h)
/// pruned and total size capped.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
});
EstadoAlarmas crearEstado(FakePuertoAlarmasAndroid android) {
final estado = EstadoAlarmas(
servicio: ServicioAlarmas(),
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
return estado;
}
const base = AlarmaMusical(
id: 'a1',
nombre: 'Diaria',
hora: 7,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
);
test('poda las entradas con mas de 24 horas (S3-R6-A)', () {
final estado = crearEstado(FakePuertoAlarmasAndroid());
final ahora = DateTime.now();
for (var i = 0; i < 100; i++) {
estado.marcarEjecucionGestionada(
base.copyWith(
proximaEjecucion: ahora.subtract(Duration(hours: 25, minutes: i)),
),
);
}
estado.marcarEjecucionGestionada(
base.copyWith(
id: 'fresca',
proximaEjecucion: ahora.add(const Duration(minutes: 5)),
),
);
expect(
estado.ejecucionesEmitidasLength,
lessThanOrEqualTo(EstadoAlarmas.maxEjecucionesEmitidas),
);
expect(
estado.ejecucionesEmitidasLength,
1,
reason: 'solo la entrada fresca sobrevive a la poda por antiguedad',
);
});
test('limita el total de entradas al tope configurado', () {
final estado = crearEstado(FakePuertoAlarmasAndroid());
final ahora = DateTime.now();
for (var i = 0; i < EstadoAlarmas.maxEjecucionesEmitidas + 50; i++) {
estado.marcarEjecucionGestionada(
base.copyWith(proximaEjecucion: ahora.add(Duration(minutes: i))),
);
}
expect(
estado.ejecucionesEmitidasLength,
lessThanOrEqualTo(EstadoAlarmas.maxEjecucionesEmitidas),
);
});
}
+8
View File
@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/modelos/grupo_favoritos.dart';
import 'package:pluriwave/modelos/preset_ecualizador.dart';
@@ -20,9 +21,16 @@ class FakeServicioAudio extends ServicioAudio {
final List<bool> cambiosEcualizadorActivo = [];
final List<double> volumenesAplicados = [];
int pausas = 0;
int configuracionesL10n = 0;
Emisora? _emisoraActual;
EstadoReproduccion _estadoActual = EstadoReproduccion.detenido;
@override
void configurarLocalizaciones(AppLocalizations l10n) {
// No global handler in tests; just record the call.
configuracionesL10n++;
}
@override
Emisora? get emisoraActual => _emisoraActual;
+4
View File
@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/modelos/alarma_musical.dart';
import 'package:pluriwave/servicios/servicio_alarmas_android.dart';
import 'package:pluriwave/servicios/servicio_grabacion_radio.dart';
@@ -22,6 +23,9 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid {
@override
Stream<EventoAlarmaAndroid> get eventosAlarma => _eventos.stream;
@override
void configurarLocalizaciones(AppLocalizations l10n) {}
@override
Future<void> programar(AlarmaMusical alarma) async {
programadas.add(alarma);
@@ -0,0 +1,53 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/servicios/servicio_alarmas_android.dart';
/// S3-R2: the event controller and handler flag must be instance state so
/// two bridges created independently never share events.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
Future<void> emitirAlarmFired(String canal, Map<String, Object?> payload) {
final mensaje = const StandardMethodCodec().encodeMethodCall(
MethodCall('alarmFired', payload),
);
return TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.handlePlatformMessage(canal, mensaje, (_) {});
}
test('dos instancias no comparten el stream de eventos (S3-R2-A)', () async {
final servicioA = ServicioAlarmasAndroid(
channel: const MethodChannel('pluriwave/alarm_scheduler_test_a'),
);
final servicioB = ServicioAlarmasAndroid(
channel: const MethodChannel('pluriwave/alarm_scheduler_test_b'),
);
final eventosA = <EventoAlarmaAndroid>[];
final eventosB = <EventoAlarmaAndroid>[];
final subA = servicioA.eventosAlarma.listen(eventosA.add);
final subB = servicioB.eventosAlarma.listen(eventosB.add);
addTearDown(subA.cancel);
addTearDown(subB.cancel);
await emitirAlarmFired('pluriwave/alarm_scheduler_test_a', {
'alarmId': 'solo-a',
'alarmTitle': 'Alarma A',
'alarmAction': 'es.freetimelab.pluriwave.ALARM_FIRE',
});
await Future<void>.delayed(Duration.zero);
expect(eventosA.map((e) => e.alarmaId), ['solo-a']);
expect(eventosB, isEmpty, reason: 'B no debe ver los eventos de A');
await emitirAlarmFired('pluriwave/alarm_scheduler_test_b', {
'alarmId': 'solo-b',
'alarmTitle': 'Alarma B',
'alarmAction': 'es.freetimelab.pluriwave.ALARM_FIRE',
});
await Future<void>.delayed(Duration.zero);
expect(eventosA.map((e) => e.alarmaId), ['solo-a']);
expect(eventosB.map((e) => e.alarmaId), ['solo-b']);
});
}
@@ -0,0 +1,107 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/modelos/alarma_musical.dart';
import 'package:pluriwave/servicios/servicio_alarmas.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// SharedPreferences spy: only the members ServicioAlarmas touches are
/// implemented; everything else throws via noSuchMethod.
class _PrefsEspia implements SharedPreferences {
final Map<String, Object> _datos = {};
int escriturasString = 0;
int lecturasString = 0;
@override
String? getString(String key) {
lecturasString++;
return _datos[key] as String?;
}
@override
Future<bool> setString(String key, String value) async {
escriturasString++;
_datos[key] = value;
return true;
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
void main() {
AlarmaMusical alarmaDiaria(
ServicioAlarmas servicio,
String nombre,
int hora,
) {
return servicio.crearAlarma(
nombre: nombre,
hora: hora,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: const [],
);
}
test(
'recalcularTodas NO escribe cuando la agenda no cambio (S3-R5-A)',
() async {
final prefs = _PrefsEspia();
final reloj = DateTime(2026, 6, 11, 6, 0);
final servicio = ServicioAlarmas(prefs: prefs, reloj: () => reloj);
await servicio.guardarAlarma(alarmaDiaria(servicio, 'Sin cambios', 7));
final escriturasBase = prefs.escriturasString;
await servicio.recalcularTodas();
expect(
prefs.escriturasString,
escriturasBase,
reason: 'agenda identica => sin setString',
);
},
);
test(
'recalcularTodas escribe exactamente una vez cuando cambia (S3-R5-B)',
() async {
var ahora = DateTime(2026, 6, 11, 6, 0);
final prefs = _PrefsEspia();
final servicio = ServicioAlarmas(prefs: prefs, reloj: () => ahora);
await servicio.guardarAlarma(alarmaDiaria(servicio, 'Cambia', 7));
final escriturasBase = prefs.escriturasString;
// A day later the next execution moves, so the schedule changed.
ahora = DateTime(2026, 6, 12, 8, 0);
await servicio.recalcularTodas();
expect(prefs.escriturasString, escriturasBase + 1);
},
);
test('mutaciones concurrentes no pierden escrituras (S3-R7-A)', () async {
final prefs = _PrefsEspia();
final servicio = ServicioAlarmas(
prefs: prefs,
reloj: () => DateTime(2026, 6, 11, 6, 0),
);
final alarmaA = alarmaDiaria(servicio, 'Concurrente A', 7);
final alarmaB = alarmaDiaria(servicio, 'Concurrente B', 8);
final lecturasBase = prefs.lecturasString;
// Dispatched WITHOUT awaiting in between: without the cache + writer
// queue both read the same base config and the last write wins.
await Future.wait([
servicio.guardarAlarma(alarmaA),
servicio.guardarAlarma(alarmaB),
]);
final config = await servicio.cargar();
expect(config.alarmas.map((a) => a.id).toSet(), {alarmaA.id, alarmaB.id});
expect(
prefs.lecturasString - lecturasBase,
lessThanOrEqualTo(2),
reason:
'las mutaciones hidratan la cache UNA vez; la lectura extra es el cargar() final',
);
});
}
@@ -0,0 +1,132 @@
import 'package:audio_session/audio_session.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/servicios/servicio_audio_session.dart';
class _ObjetivoFake implements ObjetivoAudioInterrumpible {
bool intencion = false;
bool reproduciendo = false;
int pausas = 0;
int reanudaciones = 0;
final List<bool> atenuaciones = [];
@override
bool get intencionReproducir => intencion;
@override
bool get estaReproduciendo => reproduciendo;
@override
Future<void> pausar() async {
pausas++;
reproduciendo = false;
intencion = false;
}
@override
Future<void> reanudar() async {
reanudaciones++;
reproduciendo = true;
intencion = true;
}
@override
Future<void> setAtenuado(bool atenuado) async {
atenuaciones.add(atenuado);
}
}
/// S3-R1: audio-session interruptions (phone call, transient loss, duck) and
/// becoming-noisy (headphones unplugged) must pause/duck and auto-resume.
void main() {
test('interrupcion begin/pause pausa y baja la intencion (S3-R1)', () async {
final objetivo =
_ObjetivoFake()
..reproduciendo = true
..intencion = true;
final servicio = ServicioAudioSession(objetivo: objetivo);
await servicio.manejarInterrupcion(
AudioInterruptionEvent(true, AudioInterruptionType.pause),
);
expect(objetivo.pausas, 1);
expect(
objetivo.intencionReproducir,
isFalse,
reason: 'el reconnect de S7 no debe pelear con la llamada en curso',
);
});
test(
'interrupcion end/shouldResume reanuda la reproduccion (S3-R1)',
() async {
final objetivo =
_ObjetivoFake()
..reproduciendo = true
..intencion = true;
final servicio = ServicioAudioSession(objetivo: objetivo);
await servicio.manejarInterrupcion(
AudioInterruptionEvent(true, AudioInterruptionType.pause),
);
await servicio.manejarInterrupcion(
AudioInterruptionEvent(false, AudioInterruptionType.pause),
);
expect(objetivo.reanudaciones, 1);
expect(objetivo.intencionReproducir, isTrue);
},
);
test('end sin pausa previa por interrupcion NO reanuda', () async {
final objetivo = _ObjetivoFake();
final servicio = ServicioAudioSession(objetivo: objetivo);
await servicio.manejarInterrupcion(
AudioInterruptionEvent(false, AudioInterruptionType.pause),
);
expect(
objetivo.reanudaciones,
0,
reason:
'si el usuario ya estaba en pausa, el fin de llamada no arranca audio',
);
});
test('duck atenua al comenzar y restaura al terminar', () async {
final objetivo =
_ObjetivoFake()
..reproduciendo = true
..intencion = true;
final servicio = ServicioAudioSession(objetivo: objetivo);
await servicio.manejarInterrupcion(
AudioInterruptionEvent(true, AudioInterruptionType.duck),
);
await servicio.manejarInterrupcion(
AudioInterruptionEvent(false, AudioInterruptionType.duck),
);
expect(objetivo.atenuaciones, [true, false]);
expect(objetivo.pausas, 0);
});
test('becoming-noisy (auriculares desconectados) pausa (S3-R1)', () async {
final objetivo =
_ObjetivoFake()
..reproduciendo = true
..intencion = true;
final servicio = ServicioAudioSession(objetivo: objetivo);
await servicio.manejarDesconexionSalida();
expect(objetivo.pausas, 1);
// A later interruption end must NOT resume: unplugging is a hard pause.
await servicio.manejarInterrupcion(
AudioInterruptionEvent(false, AudioInterruptionType.pause),
);
expect(objetivo.reanudaciones, 0);
});
}
@@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/widgets/mini_reproductor.dart';
import 'package:provider/provider.dart';
import '../helpers/fakes.dart';
import '../helpers/fakes_alarmas.dart';
class _EstadoRadioContador extends EstadoRadio {
_EstadoRadioContador()
: super(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
iniciarAutomaticamente: false,
);
int llamadasConfigurar = 0;
@override
void configurarLocalizaciones(AppLocalizations l10n) {
llamadasConfigurar++;
super.configurarLocalizaciones(l10n);
}
}
/// S3-R3: `configurarLocalizaciones` must run once per locale change, not on
/// every rebuild triggered by playback notifications.
void main() {
testWidgets(
'configurarLocalizaciones corre una vez por locale, no por rebuild (S3-R3-A)',
(tester) async {
final estado = _EstadoRadioContador();
addTearDown(estado.dispose);
var locale = const Locale('es');
late StateSetter cambiarLocale;
await tester.pumpWidget(
ChangeNotifierProvider<EstadoRadio>.value(
value: estado,
child: StatefulBuilder(
builder: (context, setState) {
cambiarLocale = setState;
return MaterialApp(
locale: locale,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: MiniReproductor()),
);
},
),
),
);
await tester.pumpAndSettle();
// Ten rebuilds driven by playback state notifications.
for (var i = 0; i < 10; i++) {
estado.notifyListeners();
await tester.pump();
}
expect(
estado.llamadasConfigurar,
1,
reason: 'diez rebuilds con el mismo locale => una sola configuracion',
);
cambiarLocale(() => locale = const Locale('en'));
await tester.pumpAndSettle();
expect(
estado.llamadasConfigurar,
2,
reason: 'el cambio de locale debe reconfigurar exactamente una vez',
);
},
);
}