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,33 +266,38 @@ class ServicioAlarmas {
|
||||
return nuevo;
|
||||
}
|
||||
|
||||
Future<ConfiguracionAlarmas> saltarProxima(String alarmaId) async {
|
||||
final config = await cargar();
|
||||
final alarma = config.alarmas.firstWhere((a) => a.id == alarmaId);
|
||||
final proxima = alarma.proximaEjecucion;
|
||||
if (proxima == null) return config;
|
||||
Future<ConfiguracionAlarmas> saltarProxima(String alarmaId) =>
|
||||
_enCola(() async {
|
||||
final config = await _configActual();
|
||||
final alarma = config.alarmas.firstWhere((a) => a.id == alarmaId);
|
||||
final proxima = alarma.proximaEjecucion;
|
||||
if (proxima == null) return config;
|
||||
|
||||
final excepciones = [
|
||||
...config.excepciones,
|
||||
ExcepcionAlarma(alarmaId: alarmaId, ejecucion: proxima, tipo: 'skipNext'),
|
||||
];
|
||||
final alarmas =
|
||||
config.alarmas
|
||||
.map(
|
||||
(a) =>
|
||||
a.id == alarmaId
|
||||
? _recalcular(a, config.vacaciones, excepciones)
|
||||
: a,
|
||||
)
|
||||
.toList();
|
||||
final nuevo = ConfiguracionAlarmas(
|
||||
alarmas: alarmas,
|
||||
vacaciones: config.vacaciones,
|
||||
excepciones: excepciones,
|
||||
);
|
||||
await _guardar(nuevo);
|
||||
return nuevo;
|
||||
}
|
||||
final excepciones = [
|
||||
...config.excepciones,
|
||||
ExcepcionAlarma(
|
||||
alarmaId: alarmaId,
|
||||
ejecucion: proxima,
|
||||
tipo: 'skipNext',
|
||||
),
|
||||
];
|
||||
final alarmas =
|
||||
config.alarmas
|
||||
.map(
|
||||
(a) =>
|
||||
a.id == alarmaId
|
||||
? _recalcular(a, config.vacaciones, excepciones)
|
||||
: a,
|
||||
)
|
||||
.toList();
|
||||
final nuevo = ConfiguracionAlarmas(
|
||||
alarmas: alarmas,
|
||||
vacaciones: config.vacaciones,
|
||||
excepciones: excepciones,
|
||||
);
|
||||
await _guardar(nuevo);
|
||||
return nuevo;
|
||||
});
|
||||
|
||||
Future<ConfiguracionAlarmas> posponerEjecucion(
|
||||
String alarmaId,
|
||||
@@ -276,8 +322,8 @@ class ServicioAlarmas {
|
||||
String alarmaId,
|
||||
DateTime ejecucion,
|
||||
DateTime snoozeHasta,
|
||||
) async {
|
||||
final config = await cargar();
|
||||
) => _enCola(() async {
|
||||
final config = await _configActual();
|
||||
final ahora = _reloj();
|
||||
final alarmas =
|
||||
config.alarmas
|
||||
@@ -300,13 +346,13 @@ class ServicioAlarmas {
|
||||
);
|
||||
await _guardar(nuevo);
|
||||
return nuevo;
|
||||
}
|
||||
});
|
||||
|
||||
Future<ConfiguracionAlarmas> completarEjecucion(
|
||||
String alarmaId,
|
||||
DateTime ejecucion,
|
||||
) async {
|
||||
final config = await cargar();
|
||||
) => _enCola(() async {
|
||||
final config = await _configActual();
|
||||
final ahora = _reloj();
|
||||
final alarmas =
|
||||
config.alarmas.map((a) {
|
||||
@@ -336,7 +382,7 @@ class ServicioAlarmas {
|
||||
);
|
||||
await _guardar(nuevo);
|
||||
return nuevo;
|
||||
}
|
||||
});
|
||||
|
||||
AlarmaMusical crearAlarma({
|
||||
required String nombre,
|
||||
@@ -372,18 +418,22 @@ class ServicioAlarmas {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _guardar(ConfiguracionAlarmas config) async {
|
||||
/// Persists [config] and refreshes the in-memory cache (the cache is
|
||||
/// "invalidated" by replacing it with the just-written state).
|
||||
Future<void> _guardar(ConfiguracionAlarmas config, {String? raw}) async {
|
||||
final serializado = raw ?? _serializar(config);
|
||||
final prefs = await _resolverPrefs();
|
||||
await prefs.setString(
|
||||
_keyConfig,
|
||||
jsonEncode({
|
||||
'alarmas': config.alarmas.map((a) => a.toJson()).toList(),
|
||||
'vacaciones': config.vacaciones.map((v) => v.toJson()).toList(),
|
||||
'excepciones': config.excepciones.map((e) => e.toJson()).toList(),
|
||||
}),
|
||||
);
|
||||
await prefs.setString(_keyConfig, serializado);
|
||||
_cache = config;
|
||||
_cacheRaw = serializado;
|
||||
}
|
||||
|
||||
String _serializar(ConfiguracionAlarmas config) => jsonEncode({
|
||||
'alarmas': config.alarmas.map((a) => a.toJson()).toList(),
|
||||
'vacaciones': config.vacaciones.map((v) => v.toJson()).toList(),
|
||||
'excepciones': config.excepciones.map((e) => e.toJson()).toList(),
|
||||
});
|
||||
|
||||
AlarmaMusical _recalcular(
|
||||
AlarmaMusical alarma,
|
||||
List<RangoVacaciones> vacaciones,
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user