The ringing screen only confirmed the native-to-Flutter handoff from its playback-state listener, but app.dart pre-starts the station before pushing the screen, so `reproduciendo` could be emitted before the listener subscribed and no further event ever arrived. That branch only cancelled the fallback timer: the gated fade-in never started and the native alarm player was never told to stop, so the alarm blared at the alarm-stream volume with no 5%-to-target ramp. Previously this was a timing race the stream usually lost; gating the ramp on the confirmation made the failure deterministic. The already-playing branch now confirms the handoff explicitly (idempotent with the listener), and the ramp re-imposes its 5% start volume immediately instead of waiting for the first periodic tick. Adds the regression test mounting in the real pre-started path.
389 lines
15 KiB
Dart
389 lines
15 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:just_audio/just_audio.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../estado/estado_alarmas.dart';
|
|
import '../estado/estado_radio.dart';
|
|
import '../l10n/display_names.dart';
|
|
import '../l10n/gen/app_localizations.dart';
|
|
import '../modelos/alarma_musical.dart';
|
|
import '../servicios/servicio_audio.dart';
|
|
import '../tema/pluri_animate.dart';
|
|
import '../tema/pluriwave_theme.dart';
|
|
import '../widgets/pluri_glass_surface.dart';
|
|
import '../widgets/pluri_wave_scaffold.dart';
|
|
|
|
class PantallaAlarmaSonando extends StatefulWidget {
|
|
const PantallaAlarmaSonando({
|
|
super.key,
|
|
required this.alarma,
|
|
this.audioPrearrancado = false,
|
|
});
|
|
|
|
final AlarmaMusical alarma;
|
|
final bool audioPrearrancado;
|
|
|
|
@override
|
|
State<PantallaAlarmaSonando> createState() => _PantallaAlarmaSonandoState();
|
|
}
|
|
|
|
class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
|
|
static const _volumenInicialFadeIn = 0.05;
|
|
static const _fadeStep = Duration(milliseconds: 250);
|
|
final AudioPlayer _fallbackPlayer = AudioPlayer();
|
|
StreamSubscription<EstadoReproduccion>? _estadoSub;
|
|
Timer? _fallbackTimer;
|
|
Timer? _fadeInTimer;
|
|
bool _fallbackActivo = false;
|
|
bool _radioIntentada = false;
|
|
bool _audioFlutterConfirmado = false;
|
|
bool _volumenMediaRestaurado = false;
|
|
|
|
// Captured while mounted: dispose() also restores the media volume, and by
|
|
// then the element is defunct, so context.read() would throw there.
|
|
late final EstadoAlarmas _estadoAlarmas;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_estadoAlarmas = context.read<EstadoAlarmas>();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) => _iniciarAlarma());
|
|
}
|
|
|
|
Future<void> _iniciarAlarma() async {
|
|
final radio = context.read<EstadoRadio>();
|
|
await _fallbackPlayer.setVolume(_volumenInicialFadeIn);
|
|
await _fallbackPlayer.setLoopMode(LoopMode.one);
|
|
|
|
final emisora = widget.alarma.emisora;
|
|
if (emisora == null) {
|
|
await _iniciarFallback();
|
|
return;
|
|
}
|
|
|
|
_radioIntentada = true;
|
|
await radio.audio.setVolumen(_volumenInicialFadeIn);
|
|
if (!widget.audioPrearrancado) {
|
|
unawaited(radio.reproducir(emisora));
|
|
}
|
|
|
|
// S7-R4 boundary: only `reproduciendo` cancels the fallback timer —
|
|
// `reconectando`/`cargando` do NOT count as playing, so the 12-second
|
|
// fallback below stays authoritative during the alarm ring. Waking the
|
|
// user reliably beats reconnect persistence: if the radio is still
|
|
// retrying when the timer fires, the bundled WAV takes over.
|
|
_estadoSub = radio.estadoStream.listen((estado) {
|
|
if (estado == EstadoReproduccion.reproduciendo && mounted) {
|
|
_fallbackTimer?.cancel();
|
|
_confirmarAudioFlutterListo();
|
|
}
|
|
if (estado == EstadoReproduccion.error && mounted) {
|
|
_iniciarFallback();
|
|
}
|
|
});
|
|
|
|
_fallbackTimer = Timer(const Duration(seconds: 12), () {
|
|
if (mounted) _iniciarFallback();
|
|
});
|
|
// Pre-started audio can reach `reproduciendo` BEFORE the listener above
|
|
// subscribes (app.dart starts the station before pushing this screen),
|
|
// in which case no further state event ever arrives. The handoff
|
|
// confirmation and the fade-in must not depend on catching that
|
|
// already-missed event, so this branch confirms explicitly too
|
|
// (idempotent — the listener firing as well is harmless).
|
|
if (widget.audioPrearrancado && radio.audio.estaSonando) {
|
|
_fallbackTimer?.cancel();
|
|
await _confirmarAudioFlutterListo();
|
|
}
|
|
}
|
|
|
|
Future<void> _iniciarFallback() async {
|
|
if (_fallbackActivo) return;
|
|
_fallbackActivo = true;
|
|
await _fallbackPlayer.setAsset(_assetFallback(widget.alarma.sonidoInterno));
|
|
await _fallbackPlayer.play();
|
|
await _confirmarAudioFlutterListo();
|
|
if (mounted) setState(() {});
|
|
}
|
|
|
|
void _iniciarFadeIn() {
|
|
_fadeInTimer?.cancel();
|
|
final volumenObjetivo = widget.alarma.volumen.clamp(0.0, 1.0);
|
|
final inicio = _volumenInicialFadeIn.clamp(0.0, volumenObjetivo);
|
|
final segundosFade = widget.alarma.fadeInSegundos.clamp(0, 60);
|
|
if (segundosFade <= 0 || volumenObjetivo <= inicio) {
|
|
unawaited(_aplicarVolumenGlobal(volumenObjetivo));
|
|
return;
|
|
}
|
|
// Re-impose the ramp's start volume immediately: the first periodic tick
|
|
// only lands after _fadeStep, and by now the player may have been
|
|
// recreated or re-leveled since the pre-start set it to 5%.
|
|
unawaited(_aplicarVolumenGlobal(inicio));
|
|
final duracionTotalMs = segundosFade * 1000;
|
|
final pasos = (duracionTotalMs / _fadeStep.inMilliseconds).ceil();
|
|
var pasoActual = 0;
|
|
_fadeInTimer = Timer.periodic(_fadeStep, (timer) {
|
|
if (!mounted) {
|
|
timer.cancel();
|
|
return;
|
|
}
|
|
pasoActual++;
|
|
final t = (pasoActual / pasos).clamp(0.0, 1.0);
|
|
final volumenActual = inicio + (volumenObjetivo - inicio) * t;
|
|
unawaited(_aplicarVolumenGlobal(volumenActual));
|
|
if (t >= 1) timer.cancel();
|
|
});
|
|
}
|
|
|
|
Future<void> _aplicarVolumenGlobal(double volumen) async {
|
|
if (!mounted) return;
|
|
final radio = context.read<EstadoRadio>();
|
|
await radio.audio.setVolumen(volumen.clamp(0.0, 1.0));
|
|
await _fallbackPlayer.setVolume(volumen.clamp(0.0, 1.0));
|
|
}
|
|
|
|
/// Confirms the native-to-Flutter audio handoff at most once per screen
|
|
/// instance, then starts the single audible Dart fade-in ramp (Slice 3:
|
|
/// fade-in dedup at handoff). The native ramp
|
|
/// (`PluriWaveAlarmService.startFadeIn`) owns audio until this
|
|
/// confirmation lands; starting the Dart ramp any earlier would
|
|
/// interleave both ramps and produce an audible jump/reset. If the
|
|
/// native confirmation channel call fails (dead or never-there native
|
|
/// side), the fade-in still starts in the `finally` block below — Dart
|
|
/// is the only audible source either way, so the ring must never stay
|
|
/// stuck at [_volumenInicialFadeIn] forever.
|
|
Future<void> _confirmarAudioFlutterListo() async {
|
|
if (_audioFlutterConfirmado) return;
|
|
_audioFlutterConfirmado = true;
|
|
try {
|
|
await context.read<EstadoAlarmas>().android.confirmarAudioFlutter(
|
|
widget.alarma.id,
|
|
);
|
|
} catch (e) {
|
|
debugPrint('[PluriWave][alarmas] confirmar audio flutter fallo: $e');
|
|
} finally {
|
|
_iniciarFadeIn();
|
|
}
|
|
}
|
|
|
|
/// Restores the ring-scoped `STREAM_MUSIC` override at most once per
|
|
/// screen instance (Requirement: Ring-scoped device-volume override,
|
|
/// Scenario "Restore is idempotent across double-exit paths"). Both
|
|
/// `_silenciarAudio` (dismiss/snooze) and `dispose` call this; the guard
|
|
/// here plus the idempotent Kotlin-side restore together keep any
|
|
/// exit-path ordering safe. Failures never propagate — a broken restore
|
|
/// must not block dismiss/snooze.
|
|
Future<void> _restaurarVolumenMediaUnaVez() async {
|
|
if (_volumenMediaRestaurado) return;
|
|
_volumenMediaRestaurado = true;
|
|
try {
|
|
await _estadoAlarmas.android.restaurarVolumenMedia();
|
|
} catch (e) {
|
|
debugPrint('[PluriWave][alarmas] restaurar volumen media fallo: $e');
|
|
}
|
|
}
|
|
|
|
/// Shared local-audio teardown for stop and snooze (Design 2.3): the Dart
|
|
/// fallback player and fade timer MUST die before the alarm is re-programmed
|
|
/// natively, otherwise the local fallback keeps looping after snooze.
|
|
Future<void> _liberarAudioLocal() async {
|
|
_fallbackTimer?.cancel();
|
|
_fadeInTimer?.cancel();
|
|
// cancel() detiene la entrega de eventos de forma sincrona; no se espera
|
|
// su Future porque puede no resolverse hasta que el stream se cierre.
|
|
unawaited(_estadoSub?.cancel());
|
|
_estadoSub = null;
|
|
await _fallbackPlayer.stop();
|
|
}
|
|
|
|
Future<void> _detener() async {
|
|
final radio = context.read<EstadoRadio>();
|
|
final alarmas = context.read<EstadoAlarmas>();
|
|
await _silenciarAudio(radio);
|
|
// Dismiss is run from finally so a failing reschedule/teardown can never
|
|
// leave the ringing screen stuck open (which would also block the next
|
|
// ring via the _alarmaSonandoActiva guard in app.dart).
|
|
try {
|
|
await alarmas.finalizarEjecucion(widget.alarma.id);
|
|
} catch (e) {
|
|
debugPrint('[PluriWave][alarmas] finalizar ejecucion fallo: $e');
|
|
} finally {
|
|
if (mounted) _dismissScreen();
|
|
}
|
|
}
|
|
|
|
/// Flutter-first snooze (S2-R1): tears down local audio, then routes
|
|
/// through the canonical EstadoAlarmas.posponerAlarma, which hides the
|
|
/// native notification (same stop path as dismiss) and re-programs Android.
|
|
Future<void> _posponer(int minutos) async {
|
|
final radio = context.read<EstadoRadio>();
|
|
final alarmas = context.read<EstadoAlarmas>();
|
|
// Captured BEFORE dismiss (Design D4): the ringing screen dismisses by
|
|
// design below, so the messenger must outlive it for the failure
|
|
// SnackBar to still be shown.
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
await _silenciarAudio(radio);
|
|
// posponerAlarma no longer throws on a native scheduling failure (it
|
|
// records the failure into EstadoAlarmas.error and always calls
|
|
// notifyListeners instead) — the dismiss-in-finally below is now a
|
|
// structural safety net, not a workaround for an expected throw. The
|
|
// screen still closes either way (dismiss-by-design); the failure is
|
|
// reported to the user via a SnackBar, not silently swallowed.
|
|
try {
|
|
await alarmas.posponerAlarma(widget.alarma, minutos);
|
|
final error = alarmas.error;
|
|
if (error != null) {
|
|
messenger.showSnackBar(SnackBar(content: Text(error)));
|
|
}
|
|
} catch (e) {
|
|
debugPrint('[PluriWave][alarmas] posponer alarma fallo: $e');
|
|
} finally {
|
|
if (mounted) _dismissScreen();
|
|
}
|
|
}
|
|
|
|
/// Stops both audio sources (local fallback player and the radio handler)
|
|
/// without letting either failure abort the caller's dismiss flow.
|
|
Future<void> _silenciarAudio(EstadoRadio radio) async {
|
|
try {
|
|
await _liberarAudioLocal();
|
|
} catch (e) {
|
|
debugPrint('[PluriWave][alarmas] liberar audio local fallo: $e');
|
|
}
|
|
try {
|
|
await radio.audio.pausar();
|
|
} catch (e) {
|
|
debugPrint('[PluriWave][alarmas] pausar radio fallo: $e');
|
|
}
|
|
await _restaurarVolumenMediaUnaVez();
|
|
}
|
|
|
|
/// Dismisses the alarm screen safely in both live-app and dead-app states.
|
|
///
|
|
/// When the alarm screen is the root activity (launched via full-screen intent
|
|
/// from a dead app), [Navigator.canPop] returns false and calling
|
|
/// [Navigator.pop] would be a no-op. In that case [SystemNavigator.pop] is
|
|
/// used to call `Activity.finish()` and return to the home screen.
|
|
void _dismissScreen() {
|
|
final navigator = Navigator.of(context);
|
|
if (navigator.canPop()) {
|
|
navigator.pop();
|
|
} else {
|
|
SystemNavigator.pop();
|
|
}
|
|
}
|
|
|
|
List<int> _opcionesSnooze() {
|
|
final opciones = <int>{3, 5, 10};
|
|
final propio = widget.alarma.snoozeMinutos;
|
|
if (propio > 0) opciones.add(propio);
|
|
return opciones.toList()..sort();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_fallbackTimer?.cancel();
|
|
_fadeInTimer?.cancel();
|
|
_estadoSub?.cancel();
|
|
_fallbackPlayer.dispose();
|
|
unawaited(_restaurarVolumenMediaUnaVez());
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final alarma = widget.alarma;
|
|
final l10n = AppLocalizations.of(context);
|
|
final tokens = context.pluriTokens;
|
|
// Cold-GPU note (Design 2.4): PluriGlassSurface uses a BackdropFilter and
|
|
// the first frame after a screen-off FSI wake can stutter. The blur sigma
|
|
// is capped here, and reduced-motion users skip the entry animation
|
|
// entirely via pluriFadeIn.
|
|
return PluriWaveScaffold(
|
|
body: SafeArea(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(20),
|
|
child: Center(
|
|
child: PluriGlassSurface(
|
|
borderRadius: BorderRadius.circular(32),
|
|
padding: const EdgeInsets.all(24),
|
|
blurSigma: 10,
|
|
glowColor: tokens.warmCoral.withValues(alpha: 0.35),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Image.asset(
|
|
'assets/icons/alarmas/alarm_music.png',
|
|
width: 128,
|
|
height: 128,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
_hora(alarma),
|
|
style: Theme.of(context).textTheme.displayMedium?.copyWith(
|
|
fontWeight: FontWeight.w900,
|
|
letterSpacing: -2,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
localizedAlarmName(l10n, alarma.nombre),
|
|
textAlign: TextAlign.center,
|
|
style: Theme.of(context).textTheme.titleLarge,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
_fallbackActivo
|
|
? l10n.alarmRingingFallbackActive
|
|
: _radioIntentada
|
|
? l10n.alarmRingingTryingStation
|
|
: l10n.alarmRingingPreparingFallback,
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 22),
|
|
Text(
|
|
l10n.snoozeAction,
|
|
style: Theme.of(context).textTheme.labelLarge,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
alignment: WrapAlignment.center,
|
|
children: [
|
|
for (final minutos in _opcionesSnooze())
|
|
OutlinedButton.icon(
|
|
onPressed: () => _posponer(minutos),
|
|
icon: const Icon(Icons.snooze_rounded),
|
|
label: Text(l10n.alarmSnoozeOptionLabel(minutos)),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 14),
|
|
FilledButton.icon(
|
|
onPressed: _detener,
|
|
icon: const Icon(Icons.stop_rounded),
|
|
label: Text(l10n.stopAlarmAction),
|
|
),
|
|
],
|
|
),
|
|
).pluriFadeIn(context),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
String _assetFallback(SonidoInternoAlarma sonido) => switch (sonido) {
|
|
SonidoInternoAlarma.amanecer => 'assets/audio/alarm_amanecer.wav',
|
|
SonidoInternoAlarma.campanaSuave => 'assets/audio/alarm_campana_suave.wav',
|
|
SonidoInternoAlarma.pulsoDigital => 'assets/audio/alarm_pulso_digital.wav',
|
|
};
|
|
|
|
String _hora(AlarmaMusical alarma) =>
|
|
'${alarma.hora.toString().padLeft(2, '0')}:${alarma.minuto.toString().padLeft(2, '0')}';
|