Files
pluriwave/lib/pantallas/pantalla_alarma_sonando.dart
T
FreeTLab bd7f883118
Build & Deploy PluriWave / Análisis de código (push) Successful in 36s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m40s
refactor(alarm): make the ringing screen pure UI over a reduced native port
PantallaAlarmaSonando no longer owns any audio orchestration (fallback
player, dB ramp, native handoff confirm, media-volume override/restore):
it only calls EstadoAlarmas.finalizarEjecucion/posponerAlarma from
Stop/Snooze/back, keeping the single-exit guard, PopScope back=Stop and
dismiss semantics intact. The status line now reads directly from the
alarm's static config (station name or a neutral label) instead of a
live playback/handoff state.

PuertoAlarmasAndroid drops confirmarAudioFlutter,
forzarVolumenMediaParaAlarma and restaurarVolumenMedia, and app.dart no
longer pre-starts a station before pushing the ring screen. This is the
Dart half of moving to a single native ring-audio owner (WU1 of 2); the
Kotlin service rebuild lands next and keeps this intermediate state
shippable with no double audio.
2026-07-12 11:36:31 +02:00

224 lines
8.3 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../estado/estado_alarmas.dart';
import '../l10n/display_names.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/alarma_musical.dart';
import '../tema/pluri_animate.dart';
import '../tema/pluriwave_theme.dart';
import '../tema/pluriwave_tokens.dart';
import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_wave_scaffold.dart';
class PantallaAlarmaSonando extends StatefulWidget {
const PantallaAlarmaSonando({super.key, required this.alarma});
final AlarmaMusical alarma;
@override
State<PantallaAlarmaSonando> createState() => _PantallaAlarmaSonandoState();
}
class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
/// Single-exit guard: Stop, snooze and the system back gesture all funnel
/// into the same teardown; whichever lands first wins and the rest no-op,
/// so a back-press racing a button tap can never run the exit flow twice
/// (a second _dismissScreen would pop the route UNDER the alarm screen).
bool _salidaEnCurso = false;
@override
void initState() {
super.initState();
}
/// Pure UI: the ring's audio is owned entirely by the native
/// PluriWaveAlarmService. This screen only reports the outcome to
/// EstadoAlarmas; it never touches an audio player or a device-volume
/// channel.
Future<void> _detener() async {
if (_salidaEnCurso) return;
_salidaEnCurso = true;
final alarmas = context.read<EstadoAlarmas>();
// 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): 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 {
if (_salidaEnCurso) return;
_salidaEnCurso = true;
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);
// 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();
}
}
/// 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() {
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 PopScope(
// System back / predictive back must behave exactly like Stop: a plain
// route pop would run only dispose(), leaving the native ring audible
// with no alarm UI left to stop it (the native service is the sole
// audio owner and is torn down via the same finalizarEjecucion path).
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (didPop) return;
unawaited(_detener());
},
child: _cuerpo(context, alarma, l10n, tokens),
);
}
Widget _cuerpo(
BuildContext context,
AlarmaMusical alarma,
AppLocalizations l10n,
PluriWaveTokens tokens,
) {
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),
// Static status line (Design D8): sourced only from
// widget.alarma, never from a live audio/player state — the
// ring's own audio state is owned natively and this screen
// has no channel back to it.
Text(
alarma.emisora != null
? localizedStationName(l10n, alarma.emisora!.nombre)
: l10n.alarmRingingNotificationTitle,
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 _hora(AlarmaMusical alarma) =>
'${alarma.hora.toString().padLeft(2, '0')}:${alarma.minuto.toString().padLeft(2, '0')}';