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:
+21
-4
@@ -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;
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../l10n/gen/app_localizations.dart';
|
||||
import '../modelos/alarma_musical.dart';
|
||||
import '../servicios/servicio_alarmas.dart';
|
||||
import '../servicios/servicio_alarmas_android.dart';
|
||||
@@ -13,7 +14,7 @@ class EstadoAlarmas extends ChangeNotifier {
|
||||
PuertoAlarmasAndroid? android,
|
||||
SharedPreferences? prefs,
|
||||
bool iniciarAutomaticamente = true,
|
||||
}) : servicio = servicio ?? ServicioAlarmas(),
|
||||
}) : servicio = servicio ?? ServicioAlarmas(prefs: prefs),
|
||||
android = android ?? ServicioAlarmasAndroid(),
|
||||
_prefs = prefs {
|
||||
// Decision 2.1 (snooze sync): the native layer reports its own snoozes
|
||||
@@ -43,6 +44,12 @@ class EstadoAlarmas extends ChangeNotifier {
|
||||
StreamController<AlarmaMusical>.broadcast();
|
||||
final Set<String> _ejecucionesEmitidas = {};
|
||||
static const _margenDisparoLocal = Duration(seconds: 45);
|
||||
|
||||
// Bounds for _ejecucionesEmitidas (S3-R6): entries older than the
|
||||
// retention window are pruned; the set never exceeds the cap.
|
||||
static const _retencionEjecucionesEmitidas = Duration(hours: 24);
|
||||
@visibleForTesting
|
||||
static const maxEjecucionesEmitidas = 200;
|
||||
bool _cargando = false;
|
||||
String? _error;
|
||||
|
||||
@@ -128,12 +135,22 @@ class EstadoAlarmas extends ChangeNotifier {
|
||||
final proxima = alarma.proximaProgramable;
|
||||
if (proxima == null) return;
|
||||
final key = '${alarma.id}:${proxima.millisecondsSinceEpoch}';
|
||||
_ejecucionesEmitidas.add(key);
|
||||
_registrarEjecucionEmitida(key);
|
||||
debugPrint(
|
||||
'[PluriWave][alarmas] ejecucion gestionada id=${alarma.id} proxima=${proxima.toIso8601String()}',
|
||||
);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
int get ejecucionesEmitidasLength => _ejecucionesEmitidas.length;
|
||||
|
||||
/// Forwards the UI localizations to the native bridge so alarm and station
|
||||
/// names sent to Android follow the app locale (Decision 3.2 — replaces
|
||||
/// the old static `ServicioAlarmasAndroid.configurarLocalizaciones`).
|
||||
void configurarLocalizaciones(AppLocalizations l10n) {
|
||||
android.configurarLocalizaciones(l10n);
|
||||
}
|
||||
|
||||
Future<void> eliminarAlarma(String id) async {
|
||||
debugPrint('[PluriWave][alarmas] eliminar id=$id');
|
||||
final config = await servicio.eliminarAlarma(id);
|
||||
@@ -415,6 +432,7 @@ class EstadoAlarmas extends ChangeNotifier {
|
||||
|
||||
void _vigilarAlarmasVencidas() {
|
||||
final ahora = DateTime.now();
|
||||
_depurarEjecucionesEmitidas(ahora);
|
||||
for (final alarma in _alarmas) {
|
||||
final proxima = alarma.proximaProgramable;
|
||||
if (!alarma.activa || proxima == null) continue;
|
||||
@@ -422,13 +440,13 @@ class EstadoAlarmas extends ChangeNotifier {
|
||||
final key = '${alarma.id}:${proxima.millisecondsSinceEpoch}';
|
||||
final retraso = ahora.difference(proxima);
|
||||
if (retraso > _margenDisparoLocal) {
|
||||
_ejecucionesEmitidas.add(key);
|
||||
_registrarEjecucionEmitida(key);
|
||||
debugPrint(
|
||||
'[PluriWave][alarmas] vencida local ignorada por antigua id=${alarma.id} proxima=${proxima.toIso8601String()} retraso=${retraso.inSeconds}s',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (_ejecucionesEmitidas.add(key)) {
|
||||
if (_registrarEjecucionEmitida(key)) {
|
||||
debugPrint(
|
||||
'[PluriWave][alarmas] vencida local id=${alarma.id} proxima=${proxima.toIso8601String()}',
|
||||
);
|
||||
@@ -437,6 +455,37 @@ class EstadoAlarmas extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds a `alarmId:millis` key and keeps the set bounded (S3-R6).
|
||||
/// Returns whether the key was newly added (fire-dedup contract).
|
||||
bool _registrarEjecucionEmitida(String key) {
|
||||
final agregada = _ejecucionesEmitidas.add(key);
|
||||
_depurarEjecucionesEmitidas(DateTime.now());
|
||||
return agregada;
|
||||
}
|
||||
|
||||
void _depurarEjecucionesEmitidas(DateTime ahora) {
|
||||
final limite =
|
||||
ahora.subtract(_retencionEjecucionesEmitidas).millisecondsSinceEpoch;
|
||||
_ejecucionesEmitidas.removeWhere((key) => _millisDeEjecucion(key) < limite);
|
||||
if (_ejecucionesEmitidas.length <= maxEjecucionesEmitidas) return;
|
||||
// Still over the cap: evict the oldest occurrences first. Pruned keys
|
||||
// cannot re-fire because occurrences beyond _margenDisparoLocal are
|
||||
// ignored by _vigilarAlarmasVencidas anyway.
|
||||
final ordenadas =
|
||||
_ejecucionesEmitidas.toList()..sort(
|
||||
(a, b) => _millisDeEjecucion(a).compareTo(_millisDeEjecucion(b)),
|
||||
);
|
||||
_ejecucionesEmitidas.removeAll(
|
||||
ordenadas.take(_ejecucionesEmitidas.length - maxEjecucionesEmitidas),
|
||||
);
|
||||
}
|
||||
|
||||
int _millisDeEjecucion(String key) {
|
||||
final separador = key.lastIndexOf(':');
|
||||
if (separador < 0) return 0;
|
||||
return int.tryParse(key.substring(separador + 1)) ?? 0;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_refresco?.cancel();
|
||||
|
||||
@@ -16,7 +16,6 @@ import '../l10n/gen/app_localizations.dart';
|
||||
import '../modelos/emisora.dart';
|
||||
import '../modelos/grupo_favoritos.dart';
|
||||
import '../modelos/preset_ecualizador.dart';
|
||||
import '../servicios/servicio_alarmas_android.dart';
|
||||
import '../servicios/servicio_audio.dart';
|
||||
import '../servicios/servicio_ecualizador.dart';
|
||||
import '../servicios/servicio_favoritos.dart';
|
||||
@@ -38,13 +37,16 @@ class EstadoRadio extends ChangeNotifier {
|
||||
ServicioRadio? radio,
|
||||
ServicioEcualizador? servicioEcualizador,
|
||||
ServicioGrabacionRadio? servicioGrabacion,
|
||||
SharedPreferences? prefs,
|
||||
Future<File> Function()? resolverArchivoCustom,
|
||||
bool iniciarAutomaticamente = true,
|
||||
}) : audio = audio ?? ServicioAudio(),
|
||||
favoritos = favoritos ?? ServicioFavoritos(),
|
||||
radio = radio ?? ServicioRadio(),
|
||||
servicioEcualizador = servicioEcualizador ?? ServicioEcualizador(),
|
||||
grabacion = servicioGrabacion ?? ServicioGrabacionRadio(),
|
||||
servicioEcualizador =
|
||||
servicioEcualizador ?? ServicioEcualizador(prefs: prefs),
|
||||
grabacion = servicioGrabacion ?? ServicioGrabacionRadio(prefs: prefs),
|
||||
_prefs = prefs,
|
||||
_resolverArchivoCustom = resolverArchivoCustom {
|
||||
timer = ServicioTimer(this.audio);
|
||||
_escucharErroresReproduccion();
|
||||
@@ -59,8 +61,14 @@ class EstadoRadio extends ChangeNotifier {
|
||||
final ServicioRadio radio;
|
||||
final ServicioEcualizador servicioEcualizador;
|
||||
final ServicioGrabacionRadio grabacion;
|
||||
final SharedPreferences? _prefs;
|
||||
final Future<File> Function()? _resolverArchivoCustom;
|
||||
|
||||
/// Single startup instance injected from main() (S3-R4); falls back to
|
||||
/// getInstance() only when nothing was injected (tests, legacy callers).
|
||||
Future<SharedPreferences> _resolverPrefs() async =>
|
||||
_prefs ?? SharedPreferences.getInstance();
|
||||
|
||||
AppLocalizations get _textos {
|
||||
final actual = _l10n;
|
||||
if (actual != null) return actual;
|
||||
@@ -71,7 +79,9 @@ class EstadoRadio extends ChangeNotifier {
|
||||
_l10n = l10n;
|
||||
audio.configurarLocalizaciones(l10n);
|
||||
grabacion.configurarLocalizaciones(l10n);
|
||||
ServicioAlarmasAndroid.configurarLocalizaciones(l10n);
|
||||
// The alarm bridge gets its localizations through
|
||||
// EstadoAlarmas.configurarLocalizaciones (Decision 3.2) — the old
|
||||
// static ServicioAlarmasAndroid shim is gone.
|
||||
}
|
||||
|
||||
late final ServicioTimer timer;
|
||||
@@ -332,7 +342,7 @@ class EstadoRadio extends ChangeNotifier {
|
||||
|
||||
Future<void> cambiarEmisoraPreferida(Emisora? emisora) async {
|
||||
_emisoraPreferidaUuid = emisora?.uuid;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _resolverPrefs();
|
||||
if (_emisoraPreferidaUuid == null) {
|
||||
await prefs.remove(_keyEmisoraPreferida);
|
||||
} else {
|
||||
@@ -349,7 +359,7 @@ class EstadoRadio extends ChangeNotifier {
|
||||
|
||||
Future<void> _cargarTimerSuenoPresets() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _resolverPrefs();
|
||||
final raw = prefs.getString(_keyTimerSuenoPresets);
|
||||
if (raw == null) return;
|
||||
final decoded = jsonDecode(raw);
|
||||
@@ -371,12 +381,12 @@ class EstadoRadio extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<void> _cargarEmisoraPreferida() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _resolverPrefs();
|
||||
_emisoraPreferidaUuid = prefs.getString(_keyEmisoraPreferida);
|
||||
}
|
||||
|
||||
Future<void> _cargarOrdenListas() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _resolverPrefs();
|
||||
final raw = prefs.getString(_keyOrdenListas);
|
||||
_ordenListas = switch (raw) {
|
||||
'nombre' => OrdenEmisoras.nombre,
|
||||
@@ -387,7 +397,7 @@ class EstadoRadio extends ChangeNotifier {
|
||||
|
||||
Future<void> cambiarOrdenListas(OrdenEmisoras orden) async {
|
||||
_ordenListas = orden;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _resolverPrefs();
|
||||
await prefs.setString(_keyOrdenListas, orden.name);
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -396,7 +406,7 @@ class EstadoRadio extends ChangeNotifier {
|
||||
final preferida = _resolverEmisoraPreferida();
|
||||
if (preferida?.uuid == _emisoraPreferidaUuid) return;
|
||||
_emisoraPreferidaUuid = preferida?.uuid;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _resolverPrefs();
|
||||
if (_emisoraPreferidaUuid == null) {
|
||||
await prefs.remove(_keyEmisoraPreferida);
|
||||
} else {
|
||||
@@ -905,23 +915,22 @@ class EstadoRadio extends ChangeNotifier {
|
||||
Future<Map<String, dynamic>> exportarConfig() async {
|
||||
final favs = await favoritos.obtenerTodos();
|
||||
final grupos = await favoritos.obtenerGrupos();
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _resolverPrefs();
|
||||
|
||||
// Alarmas: leemos el JSON crudo de SharedPreferences para no duplicar
|
||||
// lógica de ServicioAlarmas y evitar inyectar una dependencia nueva.
|
||||
final alarmasRaw = prefs.getString(_keyAlarmasConfig);
|
||||
final alarmasData =
|
||||
alarmasRaw != null ? jsonDecode(alarmasRaw) as Map<String, dynamic> : null;
|
||||
alarmasRaw != null
|
||||
? jsonDecode(alarmasRaw) as Map<String, dynamic>
|
||||
: null;
|
||||
|
||||
return {
|
||||
'version': 2,
|
||||
'exportedAt': DateTime.now().toIso8601String(),
|
||||
// Favoritos + grupos (preserva asignaciones grupo_id en cada emisora)
|
||||
'gruposFavoritos':
|
||||
grupos
|
||||
.where((g) => !g.esSinAsignar)
|
||||
.map((g) => g.toMap())
|
||||
.toList(),
|
||||
grupos.where((g) => !g.esSinAsignar).map((g) => g.toMap()).toList(),
|
||||
'favoritos': favs.map((e) => e.toMap()).toList(),
|
||||
// Emisoras personalizadas
|
||||
'emisorasCustom': _emisorasCustom.map((e) => e.toMap()).toList(),
|
||||
@@ -945,7 +954,7 @@ class EstadoRadio extends ChangeNotifier {
|
||||
final version = data['version'] as int? ?? 1;
|
||||
if (version > 2) throw Exception(_textos.unsupportedConfigVersion);
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _resolverPrefs();
|
||||
|
||||
// ── Grupos de favoritos (v2) ──────────────────────────────────────────
|
||||
// Restauramos primero para que al agregar favoritos ya existan los grupos.
|
||||
@@ -1080,7 +1089,7 @@ class EstadoRadio extends ChangeNotifier {
|
||||
normalizados.isEmpty
|
||||
? List<int>.from(_timerSuenoPresetsDefecto)
|
||||
: normalizados.take(12).toList();
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _resolverPrefs();
|
||||
await prefs.setString(
|
||||
_keyTimerSuenoPresets,
|
||||
jsonEncode(_timerSuenoPresetsSegundos),
|
||||
@@ -1103,7 +1112,7 @@ class EstadoRadio extends ChangeNotifier {
|
||||
|
||||
Future<void> restaurarTimerSuenoPresets() async {
|
||||
_timerSuenoPresetsSegundos = List<int>.from(_timerSuenoPresetsDefecto);
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _resolverPrefs();
|
||||
await prefs.remove(_keyTimerSuenoPresets);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
+14
-6
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,15 +266,20 @@ class ServicioAlarmas {
|
||||
return nuevo;
|
||||
}
|
||||
|
||||
Future<ConfiguracionAlarmas> saltarProxima(String alarmaId) async {
|
||||
final config = await cargar();
|
||||
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'),
|
||||
ExcepcionAlarma(
|
||||
alarmaId: alarmaId,
|
||||
ejecucion: proxima,
|
||||
tipo: 'skipNext',
|
||||
),
|
||||
];
|
||||
final alarmas =
|
||||
config.alarmas
|
||||
@@ -251,7 +297,7 @@ class ServicioAlarmas {
|
||||
);
|
||||
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,17 +418,21 @@ 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({
|
||||
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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 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: <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),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user