Replace the glass-card container with a full-bleed blurred-art background, giant heroTime display, 3 fixed snooze tiles (3/5/10 min, one highlighted), and a full-width stop pill. The status label now also renders a static "Subiendo volumen" line - no seconds counter - when the alarm has a configured fade-in, per resolution 4: the native-to-Flutter progress channel a live counter would need is deliberately absent from this architecture. The dismiss guard and force-stop retry banner are untouched: the banner is byte-identical to its pre-restyle form, only repositioned, and the guard test's own diff against main stays empty. size:exception: 530 changed lines (440+/90-) against the 200-300 forecast - lib/ alone is 302 lines, at the edge of the band; the two touched test files account for the rest. Not split further: this is one cohesive restyle to the single screen in this branch where an inconsistent intermediate state is least acceptable.
507 lines
19 KiB
Dart
507 lines
19 KiB
Dart
import 'dart:async';
|
|
import 'dart:ui';
|
|
|
|
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_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();
|
|
}
|
|
}
|
|
|
|
@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,
|
|
) {
|
|
final type = context.pluriType;
|
|
// WU11 (native-alarms delta — restyle, drop live countdown label):
|
|
// full-bleed blurred art replaces the PluriGlassSurface card. Cold-GPU
|
|
// note (Design 2.4) still applies to the entry animation below, which
|
|
// is why it stays on the foreground content only, not the background.
|
|
return PluriWaveScaffold(
|
|
body: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
_FondoArteDifuminado(tokens: tokens),
|
|
SafeArea(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(24, 20, 24, 24),
|
|
child: Column(
|
|
children: [
|
|
const Spacer(flex: 2),
|
|
FittedBox(
|
|
fit: BoxFit.scaleDown,
|
|
child: Text(
|
|
_hora(alarma),
|
|
key: const ValueKey('ringing-hero-time'),
|
|
style: type.heroTime,
|
|
),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
localizedAlarmName(l10n, alarma.nombre),
|
|
textAlign: TextAlign.center,
|
|
style: type.bodyStrong,
|
|
),
|
|
const SizedBox(height: 22),
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(tokens.radiusLg),
|
|
child: Image.asset(
|
|
'assets/icons/alarmas/alarm_music.png',
|
|
width: 168,
|
|
height: 168,
|
|
fit: BoxFit.cover,
|
|
errorBuilder:
|
|
(_, __, ___) => Icon(
|
|
Icons.music_note_rounded,
|
|
size: 96,
|
|
color: tokens.warmCoral,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 18),
|
|
// 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,
|
|
style: type.cardTitle,
|
|
),
|
|
// WU11 (native-alarms delta — Ringing Screen Shows a
|
|
// Static Status Label): only rendered while this alarm was
|
|
// actually configured with a fade-in; a STATIC label, no
|
|
// seconds suffix, no ticking value — the native→Flutter
|
|
// progress channel that a live countdown would need is
|
|
// deliberately absent from this architecture (resolution
|
|
// 4). Not spec-tested to also disappear once the fade-in
|
|
// period elapses: this screen has no clock signal to know
|
|
// when that is, and inventing one would be exactly the
|
|
// out-of-scope plumbing being avoided.
|
|
if (alarma.fadeInSegundos > 0) ...[
|
|
const SizedBox(height: 4),
|
|
_EstadoSubidaVolumen(l10n: l10n, tokens: tokens),
|
|
],
|
|
const Spacer(flex: 3),
|
|
Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: Text(
|
|
l10n.snoozeAction,
|
|
style: type.eyebrowLabel.copyWith(
|
|
color: tokens.warmCoral,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 10),
|
|
_FilaSnoozeFija(
|
|
alarma: alarma,
|
|
l10n: l10n,
|
|
tokens: tokens,
|
|
onPosponer: _posponer,
|
|
),
|
|
const SizedBox(height: 14),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: FilledButton.icon(
|
|
key: const ValueKey('ringing-stop-button'),
|
|
style: FilledButton.styleFrom(
|
|
minimumSize: const Size.fromHeight(76),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(tokens.radiusLg),
|
|
),
|
|
),
|
|
onPressed: _detener,
|
|
icon: const Icon(Icons.stop_circle_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')}';
|
|
|
|
/// Full-bleed blurred backdrop (WU11, replaces the `PluriGlassSurface` card
|
|
/// container per task 11.3). This app has no per-station artwork/favicon
|
|
/// safe to render here: `Emisora.favicon` is a network URL, and rendering
|
|
/// one via `Image.network` inside a widget test hangs/throws without a
|
|
/// mocked `HttpClient` — a hazard no other screen in this codebase accepts
|
|
/// either. The existing bundled alarm asset is reused instead, heavily
|
|
/// blurred and stretched; purely decorative, not spec-tested.
|
|
class _FondoArteDifuminado extends StatelessWidget {
|
|
const _FondoArteDifuminado({required this.tokens});
|
|
|
|
final PluriWaveTokens tokens;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Positioned.fill(
|
|
key: const ValueKey('ringing-background-art'),
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
ImageFiltered(
|
|
imageFilter: ImageFilter.blur(sigmaX: 44, sigmaY: 44),
|
|
child: Opacity(
|
|
opacity: 0.5,
|
|
child: Image.asset(
|
|
'assets/icons/alarmas/alarm_music.png',
|
|
fit: BoxFit.cover,
|
|
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
|
|
),
|
|
),
|
|
),
|
|
DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [
|
|
tokens.deepViolet.withValues(alpha: 0.55),
|
|
tokens.deepViolet.withValues(alpha: 0.9),
|
|
tokens.deepViolet,
|
|
],
|
|
stops: const [0, 0.55, 1],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Static "turning up the volume" status (native-alarms delta, WU11 —
|
|
/// Ringing Screen Shows a Static Status Label): a dot + label, no
|
|
/// `AnimationController`/`Animate` anywhere in this widget. A pulsing dot
|
|
/// would reintroduce the exact "`pumpAndSettle()` never terminates" hazard
|
|
/// WU5 documented for `VisualizadorAudio`'s own repeating controller — this
|
|
/// screen must stay safe for `pumpAndSettle()` in every other existing test.
|
|
class _EstadoSubidaVolumen extends StatelessWidget {
|
|
const _EstadoSubidaVolumen({required this.l10n, required this.tokens});
|
|
|
|
final AppLocalizations l10n;
|
|
final PluriWaveTokens tokens;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Row(
|
|
key: const ValueKey('estado-subida-volumen'),
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(
|
|
width: 6,
|
|
height: 6,
|
|
decoration: BoxDecoration(
|
|
color: tokens.liveGreen,
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
l10n.alarmVolumeRisingStatus,
|
|
style: context.pluriType.bodyStrong.copyWith(color: tokens.liveGreen),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The ringing screen's snooze row (native-alarms delta, WU11): exactly 3
|
|
/// FIXED tiles (3/5/10 min), replacing the previous variable-length `Wrap`
|
|
/// that grew a 4th tile for a custom `snoozeMinutos`. **Design decision, not
|
|
/// spec-tested** (WU11 has no ADR): the highlighted (filled) tile is
|
|
/// whichever of the 3 matches `alarma.snoozeMinutos`; the alarm's own
|
|
/// editor-configured value still decides WHICH tile is filled, tapping any
|
|
/// tile still snoozes for exactly that tile's duration (`_posponer` is
|
|
/// called with the tapped value, never the alarm's stored default). If the
|
|
/// alarm's own value isn't one of the three — only reachable via a fixture
|
|
/// or a pre-redesign save, since the editor's own snooze picker only ever
|
|
/// offers `{3, 5, 10, current}` — 10 is the default highlight, matching the
|
|
/// mockup's own "10 min · habitual" example.
|
|
class _FilaSnoozeFija extends StatelessWidget {
|
|
const _FilaSnoozeFija({
|
|
required this.alarma,
|
|
required this.l10n,
|
|
required this.tokens,
|
|
required this.onPosponer,
|
|
});
|
|
|
|
final AlarmaMusical alarma;
|
|
final AppLocalizations l10n;
|
|
final PluriWaveTokens tokens;
|
|
final ValueChanged<int> onPosponer;
|
|
|
|
static const _opciones = [3, 5, 10];
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final destacado =
|
|
_opciones.contains(alarma.snoozeMinutos) ? alarma.snoozeMinutos : 10;
|
|
return Row(
|
|
children: [
|
|
for (final minutos in _opciones) ...[
|
|
if (minutos != _opciones.first) const SizedBox(width: 10),
|
|
_tile(minutos, minutos == destacado),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _tile(int minutos, bool esDestacado) {
|
|
final forma = RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(tokens.radiusMd),
|
|
);
|
|
final etiqueta = Text(l10n.alarmSnoozeOptionLabel(minutos));
|
|
return Expanded(
|
|
flex: esDestacado ? 3 : 2,
|
|
child: SizedBox(
|
|
height: 76,
|
|
child:
|
|
esDestacado
|
|
? FilledButton(
|
|
onPressed: () => onPosponer(minutos),
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: tokens.warmCoral,
|
|
foregroundColor: tokens.deepViolet,
|
|
shape: forma,
|
|
),
|
|
child: etiqueta,
|
|
)
|
|
: OutlinedButton(
|
|
onPressed: () => onPosponer(minutos),
|
|
style: OutlinedButton.styleFrom(
|
|
foregroundColor: tokens.warmCoral,
|
|
backgroundColor: tokens.warmCoral.withValues(alpha: 0.16),
|
|
side: BorderSide(
|
|
color: tokens.warmCoral.withValues(alpha: 0.4),
|
|
),
|
|
shape: forma,
|
|
),
|
|
child: etiqueta,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|