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.
313 lines
12 KiB
Dart
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')}';
|