Files
pluriwave/lib/pantallas/pantalla_alarma_sonando.dart
T
Javier Bautista Fernández 29f7d54e85
Build & Deploy PluriWave / Análisis de código (push) Successful in 25s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m25s
fix(alarm): fail-safe alarm system overhaul (SDD alarm-system-overhaul, slice A)
Root-cause fix for the unstoppable-alarm incident (alarm rang 15 minutes,
only uninstall silenced it) plus systematic hardening of every stop path.

Native (Kotlin):
- Verified stop: stopActiveAlarm now derives its result from the real
  post-teardown state (companion instance + synchronous stopEverything +
  activeRingingId check) instead of reporting unconditional success.
- Atomic teardown: every stop path (stop action, notification button,
  snooze, missed, onDestroy, startForeground failure) funnels through one
  stopEverything() covering audio, wakelock, notification, foreground
  state and firing-record cleanup; player.release() guarded.
- Bounded ringing: 10-minute auto-silence armed via AlarmManager fires a
  FIRED->MISSED transition with a localized missed-alarm notification;
  repeating alarms keep their native rearm, deleted alarms never produce
  ghost MISSED notifications.
- Durable firing record with onStartCommand re-validation (resurrection
  guard) and boot-time stale cleanup; firing records cleared on every
  refuse/mismatch/cancel path.
- New notification-only dismissal channel (dismissAlarmNotificationOnly)
  so UI-level dedup can never kill a live ring's audio.

Flutter (Dart):
- Stop/disable/edit/delete of a ringing alarm always attempt to silence
  it; on native-query failure the stop falls back toward silence via the
  id-scoped legacy stop.
- Verified-stop results surface failures: the ringing screen keeps
  dismiss-by-design on success, but on a verified failure it stays up
  with a persistent force-stop banner (guarded against double-dismiss)
  and auto-dismisses if the ring ends externally (missed/notification).
- Missed events sync alarm bookkeeping without opening the ringing UI.
- 4 new l10n keys translated across all 13 locales (ARB guard green).

550 tests green, analyzer clean. Reviewed in 3 adversarial 4-lens rounds
(2 deterministic + 1 refuter-corroborated critical fixed); formal
gentle-ai receipt waived by maintainer authorization (correction scope
legitimately exceeded the frozen genesis paths). On-device QA checklist
in openspec/changes/alarm-system-overhaul/tasks.md pending before
archive.
2026-07-22 23:52:36 +02:00

313 lines
12 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;
/// Retryable force-stop affordance (Finding A, spec `alarm-stop-safety` /
/// "Retryable Force-Stop Affordance"): true while a VERIFIED stop failure
/// (or an unknown-state exception) is outstanding. Unlike a timed SnackBar,
/// this drives a persistent in-screen banner that stays until a confirmed
/// stop clears it — the ring is still audible while this is true, so the
/// screen intentionally does NOT dismiss.
bool _falloDetencionVisible = false;
late final EstadoAlarmas _alarmas;
@override
void initState() {
super.initState();
_alarmas = context.read<EstadoAlarmas>();
_alarmas.addListener(_alReconciliarFinExterno);
}
/// External end-of-ring reconciliation (RES-1): if this alarm's occurrence
/// gets recorded as MISSED while this screen is up, auto-dismiss instead of
/// leaving a stale ringing screen with no audio behind it.
void _alReconciliarFinExterno() {
if (_salidaEnCurso || !mounted) return;
if (_alarmas.ultimaAlarmaPerdidaId != widget.alarma.id) return;
_salidaEnCurso = true;
_dismissScreen();
}
/// 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>();
final alarmaId = widget.alarma.id;
try {
await alarmas.finalizarEjecucion(alarmaId);
if (alarmas.error != null) {
// Verified stop failure (Finding A): the alarm is still ringing, so
// dismissing now would hide the only retry affordance. Reset the
// single-exit guard so a retry (this button again, back gesture, or
// the banner's own action below) can run the teardown again.
_salidaEnCurso = false;
if (mounted) setState(() => _falloDetencionVisible = true);
return;
}
} catch (e) {
debugPrint('[PluriWave][alarmas] finalizar ejecucion fallo: $e');
// Unknown state (Finding A): treat exactly like a verified failure —
// stay and show the retry banner. The notification's native Stop
// action remains the out-of-band fallback, and PopScope already routes
// back through this same method on a subsequent back-press.
_salidaEnCurso = false;
if (mounted) setState(() => _falloDetencionVisible = true);
return;
}
if (mounted) _dismissScreen();
}
/// Retry action bound to the persistent force-stop banner (Finding A,
/// SS-3b): re-invokes the fail-safe stop directly; dismisses ONLY on a
/// confirmed success, otherwise the banner stays exactly as it was.
Future<void> _forzarDetencion() async {
if (_salidaEnCurso) return;
_salidaEnCurso = true;
final alarmas = context.read<EstadoAlarmas>();
await alarmas.forzarDetencion(widget.alarma.id);
if (alarmas.error == null) {
if (mounted) _dismissScreen();
} else {
// Verified failure (RES-2): reset the guard so the banner's own retry
// action (or another button) can run the teardown again.
_salidaEnCurso = false;
if (mounted) setState(() {});
}
}
/// 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() {
_alarmas.removeListener(_alReconciliarFinExterno);
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),
),
if (_falloDetencionVisible) ...[
const SizedBox(height: 14),
_bannerFalloDetencion(context, l10n, tokens),
],
],
),
).pluriFadeIn(context),
),
),
),
);
}
/// Persistent force-stop retry banner (Finding A, spec `alarm-stop-safety`
/// / "Retryable Force-Stop Affordance"): an in-screen section rather than a
/// timed SnackBar, so it stays visible until [_forzarDetencion] confirms a
/// stop (or the screen is torn down externally) instead of auto-dismissing
/// after a fixed duration.
Widget _bannerFalloDetencion(
BuildContext context,
AppLocalizations l10n,
PluriWaveTokens tokens,
) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: tokens.warmCoral.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: tokens.warmCoral.withValues(alpha: 0.4)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(l10n.alarmStopFailedMessage, textAlign: TextAlign.center),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: _forzarDetencion,
child: Text(l10n.alarmForceStopAction),
),
],
),
);
}
}
String _hora(AlarmaMusical alarma) =>
'${alarma.hora.toString().padLeft(2, '0')}:${alarma.minuto.toString().padLeft(2, '0')}';