diff --git a/lib/app.dart b/lib/app.dart index 639fa0f..4976b7e 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -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( 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().configurarLocalizaciones( + AppLocalizations.of(context), + ); + } final estado = context.read(); if (identical(_estadoSuscrito, estado) && _errorSubscription != null) { return; diff --git a/lib/estado/estado_alarmas.dart b/lib/estado/estado_alarmas.dart index 920c43a..abc74b3 100644 --- a/lib/estado/estado_alarmas.dart +++ b/lib/estado/estado_alarmas.dart @@ -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.broadcast(); final Set _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 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(); diff --git a/lib/estado/estado_radio.dart b/lib/estado/estado_radio.dart index e355039..1ec80e2 100644 --- a/lib/estado/estado_radio.dart +++ b/lib/estado/estado_radio.dart @@ -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 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 Function()? _resolverArchivoCustom; + /// Single startup instance injected from main() (S3-R4); falls back to + /// getInstance() only when nothing was injected (tests, legacy callers). + Future _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 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 _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 _cargarEmisoraPreferida() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _resolverPrefs(); _emisoraPreferidaUuid = prefs.getString(_keyEmisoraPreferida); } Future _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 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> 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 : null; + alarmasRaw != null + ? jsonDecode(alarmasRaw) as Map + : 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.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 restaurarTimerSuenoPresets() async { _timerSuenoPresetsSegundos = List.from(_timerSuenoPresetsDefecto); - final prefs = await SharedPreferences.getInstance(); + final prefs = await _resolverPrefs(); await prefs.remove(_keyTimerSuenoPresets); notifyListeners(); } diff --git a/lib/main.dart b/lib/main.dart index 0c64ac5..537b64c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 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 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 _aplicarPoliticaOrientacion([ui.Display? display]) async { @@ -36,12 +47,9 @@ Future _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; } diff --git a/lib/servicios/servicio_alarmas.dart b/lib/servicios/servicio_alarmas.dart index ca347e7..716d9f5 100644 --- a/lib/servicios/servicio_alarmas.dart +++ b/lib/servicios/servicio_alarmas.dart @@ -34,9 +34,40 @@ class ServicioAlarmas { final DateTime Function() _reloj; final _uuid = const Uuid(); - Future 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 _cola = Future.value(); + + Future _enCola(Future 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 cargar() => _enCola(() { + _cache = null; + _cacheRaw = null; + return _configActual(); + }); + + Future _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? vacaciones, List? excepciones, - }) async { - final config = await cargar(); + }) => _enCola(() async { + final config = await _configActual(); final ahora = _reloj(); final alarmas = List.from(config.alarmas); final index = alarmas.indexWhere((a) => a.id == alarma.id); @@ -105,10 +136,10 @@ class ServicioAlarmas { ); await _guardar(nuevo); return nuevo; - } + }); - Future eliminarAlarma(String id) async { - final config = await cargar(); + Future 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 guardarVacaciones( List 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 recalcularTodas() async { - final config = await cargar(); + Future 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 sincronizarEjecucionesNativas( Map ejecuciones, - ) async { + ) { if (ejecuciones.isEmpty) return cargar(); + return _enCola(() => _sincronizarEjecucionesNativasInterno(ejecuciones)); + } - final config = await cargar(); + Future _sincronizarEjecucionesNativasInterno( + Map ejecuciones, + ) async { + final config = await _configActual(); final ahora = _reloj(); var huboCambios = false; final alarmas = @@ -225,33 +266,38 @@ class ServicioAlarmas { return nuevo; } - Future 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 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 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 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 _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 _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 vacaciones, diff --git a/lib/servicios/servicio_alarmas_android.dart b/lib/servicios/servicio_alarmas_android.dart index 78d5258..50584df 100644 --- a/lib/servicios/servicio_alarmas_android.dart +++ b/lib/servicios/servicio_alarmas_android.dart @@ -126,6 +126,10 @@ class EjecucionAlarmaNativa { abstract class PuertoAlarmasAndroid { Stream get eventosAlarma; + /// Provides the UI localizations used to localize the alarm/station names + /// sent to the native scheduler. + void configurarLocalizaciones(AppLocalizations l10n); + Future programar(AlarmaMusical alarma); Future cancelar(String alarmaId); Future 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.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.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(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) { diff --git a/lib/servicios/servicio_audio.dart b/lib/servicios/servicio_audio.dart index f56aa8e..a6b31fa 100644 --- a/lib/servicios/servicio_audio.dart +++ b/lib/servicios/servicio_audio.dart @@ -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 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 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 pausar() => pause(); + + @override + Future reanudar() => play(); + + @override + Future setAtenuado(bool atenuado) async { + if (_atenuado == atenuado) return; + _atenuado = atenuado; + await _player.setVolume(_volumenEfectivo); } @override - Future play() => _player.play(); + Future play() { + _intencionReproducir = true; + return _player.play(); + } @override - Future pause() => _player.pause(); + Future pause() { + _intencionReproducir = false; + return _player.pause(); + } @override Future stop() async { + _intencionReproducir = false; _revisionFuente++; await _player.stop(); emisoraActual = null; diff --git a/lib/servicios/servicio_audio_session.dart b/lib/servicios/servicio_audio_session.dart new file mode 100644 index 0000000..61047c9 --- /dev/null +++ b/lib/servicios/servicio_audio_session.dart @@ -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 pausar(); + + Future reanudar(); + + /// Temporarily lowers ("ducks") the output volume without pausing. + Future 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 Function()? obtenerSesion, + }) : _objetivo = objetivo, + _obtenerSesion = obtenerSesion ?? (() => AudioSession.instance); + + final ObjetivoAudioInterrumpible _objetivo; + final Future Function() _obtenerSesion; + StreamSubscription? _interrupcionesSub; + StreamSubscription? _ruidoSub; + + /// True when WE paused because of an interruption; only then does an + /// interruption end with shouldResume restart playback. + bool _pausadoPorInterrupcion = false; + + Future 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 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 manejarDesconexionSalida() async { + // Headphones unplugged: hard pause, never auto-resume afterwards. + _pausadoPorInterrupcion = false; + if (_objetivo.estaReproduciendo) { + await _objetivo.pausar(); + } + } + + Future dispose() async { + await _interrupcionesSub?.cancel(); + await _ruidoSub?.cancel(); + } +} diff --git a/lib/servicios/servicio_contenido_app.dart b/lib/servicios/servicio_contenido_app.dart index d065c7f..87902c5 100644 --- a/lib/servicios/servicio_contenido_app.dart +++ b/lib/servicios/servicio_contenido_app.dart @@ -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 _resolverPrefs() async => + _prefs ?? SharedPreferences.getInstance(); + Future 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 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 = diff --git a/lib/servicios/servicio_ecualizador.dart b/lib/servicios/servicio_ecualizador.dart index f272d35..f7b5f0a 100644 --- a/lib/servicios/servicio_ecualizador.dart +++ b/lib/servicios/servicio_ecualizador.dart @@ -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 _resolverPrefs() async => + _prefs ?? SharedPreferences.getInstance(); + Future 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 guardarPrincipal(PresetEcualizador preset) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _resolverPrefs(); await prefs.setString(_keyPresetPrincipal, jsonEncode(preset.toJson())); } Future 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 guardarActivo(bool activo) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _resolverPrefs(); await prefs.setBool(_keyActivo, activo); } Future 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 guardarConfiguracion(ConfiguracionEcualizador config) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _resolverPrefs(); await prefs.setString( _keyPresetPrincipal, jsonEncode(config.principal.toJson()), diff --git a/lib/servicios/servicio_grabacion_radio.dart b/lib/servicios/servicio_grabacion_radio.dart index 41d4e0f..856b09c 100644 --- a/lib/servicios/servicio_grabacion_radio.dart +++ b/lib/servicios/servicio_grabacion_radio.dart @@ -82,9 +82,11 @@ class ServicioGrabacionRadio { http.Client? cliente, Future 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 Function()? _resolverDirectorioBase; final DateTime Function() _reloj; + final SharedPreferences? _prefs; + + /// Injected startup instance (S3-R4); getInstance() is only a fallback. + Future _resolverPrefs() async => + _prefs ?? SharedPreferences.getInstance(); final _estadoController = StreamController.broadcast(); AppLocalizations? _l10n; @@ -123,7 +130,7 @@ class ServicioGrabacionRadio { Future 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 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); diff --git a/lib/widgets/mini_reproductor.dart b/lib/widgets/mini_reproductor.dart index 26cd004..6a62d56 100644 --- a/lib/widgets/mini_reproductor.dart +++ b/lib/widgets/mini_reproductor.dart @@ -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 createState() => _MiniReproductorState(); +} + +class _MiniReproductorState extends State { + 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().configurarLocalizaciones( + AppLocalizations.of(context), + ); + } + } + @override Widget build(BuildContext context) { final estado = context.watch(); final l10n = AppLocalizations.of(context); - estado.configurarLocalizaciones(l10n); final emisora = estado.emisoraActual; if (emisora == null) return const SizedBox.shrink(); diff --git a/openspec/changes/app-quality-and-native-alarms/apply-progress.md b/openspec/changes/app-quality-and-native-alarms/apply-progress.md index 6d1ac1b..9e82df9 100644 --- a/openspec/changes/app-quality-and-native-alarms/apply-progress.md +++ b/openspec/changes/app-quality-and-native-alarms/apply-progress.md @@ -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 D1–D5 / S1–S5) @@ -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: `; 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. diff --git a/openspec/changes/app-quality-and-native-alarms/tasks.md b/openspec/changes/app-quality-and-native-alarms/tasks.md index 7cc72d7..c352840 100644 --- a/openspec/changes/app-quality-and-native-alarms/tasks.md +++ b/openspec/changes/app-quality-and-native-alarms/tasks.md @@ -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 _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. diff --git a/test/estado/estado_alarmas_ejecuciones_test.dart b/test/estado/estado_alarmas_ejecuciones_test.dart new file mode 100644 index 0000000..76ee034 --- /dev/null +++ b/test/estado/estado_alarmas_ejecuciones_test.dart @@ -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), + ); + }); +} diff --git a/test/helpers/fakes.dart b/test/helpers/fakes.dart index eff40d8..f4dcbc9 100644 --- a/test/helpers/fakes.dart +++ b/test/helpers/fakes.dart @@ -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 cambiosEcualizadorActivo = []; final List 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; diff --git a/test/helpers/fakes_alarmas.dart b/test/helpers/fakes_alarmas.dart index 514f82c..15fa439 100644 --- a/test/helpers/fakes_alarmas.dart +++ b/test/helpers/fakes_alarmas.dart @@ -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 get eventosAlarma => _eventos.stream; + @override + void configurarLocalizaciones(AppLocalizations l10n) {} + @override Future programar(AlarmaMusical alarma) async { programadas.add(alarma); diff --git a/test/servicios/servicio_alarmas_android_instance_test.dart b/test/servicios/servicio_alarmas_android_instance_test.dart new file mode 100644 index 0000000..9a53e06 --- /dev/null +++ b/test/servicios/servicio_alarmas_android_instance_test.dart @@ -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 emitirAlarmFired(String canal, Map 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 = []; + final eventosB = []; + 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.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.delayed(Duration.zero); + + expect(eventosA.map((e) => e.alarmaId), ['solo-a']); + expect(eventosB.map((e) => e.alarmaId), ['solo-b']); + }); +} diff --git a/test/servicios/servicio_alarmas_cache_test.dart b/test/servicios/servicio_alarmas_cache_test.dart new file mode 100644 index 0000000..9a3b0fa --- /dev/null +++ b/test/servicios/servicio_alarmas_cache_test.dart @@ -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 _datos = {}; + int escriturasString = 0; + int lecturasString = 0; + + @override + String? getString(String key) { + lecturasString++; + return _datos[key] as String?; + } + + @override + Future 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', + ); + }); +} diff --git a/test/servicios/servicio_audio_session_test.dart b/test/servicios/servicio_audio_session_test.dart new file mode 100644 index 0000000..bc8798c --- /dev/null +++ b/test/servicios/servicio_audio_session_test.dart @@ -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 atenuaciones = []; + + @override + bool get intencionReproducir => intencion; + + @override + bool get estaReproduciendo => reproduciendo; + + @override + Future pausar() async { + pausas++; + reproduciendo = false; + intencion = false; + } + + @override + Future reanudar() async { + reanudaciones++; + reproduciendo = true; + intencion = true; + } + + @override + Future 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); + }); +} diff --git a/test/widgets/mini_reproductor_configurar_test.dart b/test/widgets/mini_reproductor_configurar_test.dart new file mode 100644 index 0000000..e89555f --- /dev/null +++ b/test/widgets/mini_reproductor_configurar_test.dart @@ -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.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', + ); + }, + ); +}