Files
pluriwave/lib/pantallas/pantalla_alarma_sonando.dart
T
FreeTLab 36d7d5f692 fix(alarma-sonando): date line, additive snooze qualifier
Audit 9.4 (t4:419): "Lunes, 3 de agosto" now renders between the
schedule pill and the hero time -- purely additive, a new sibling
Text touching neither element. New formato_fechas.dart helper
fechaLargaConDiaSemana (locale-aware via DateFormat.MMMMEEEEd).

Audit 9.10 (t4:433): the highlighted snooze tile gains a "usual"
qualifier (new ARB key alarmSnoozeUsualLabel) alongside the original
flat label -- resolved DIFFERENTLY than its sibling 9.9 (permanently
rejected, id 2525): instead of splitting the flat
alarmSnoozeOptionLabel string into two differently-sized Text nodes
(which would make it vanish from the render tree the protected
dismiss-guard test locates via find.text), a SEPARATE small Text is
added alongside it. The original label stays a single, untouched Text
node, still inside the same FilledButton the guard taps.

Verified against the full ringing-screen test surface (34 tests
across 5 files, including the protected dismiss-guard file) -- all
green, dismiss-guard file reconfirmed byte-identical to main.
2026-07-30 16:45:29 +02:00

771 lines
30 KiB
Dart

import 'dart:async';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:provider/provider.dart';
import '../estado/estado_alarmas.dart';
import '../l10n/display_names.dart';
import '../l10n/formato_fechas.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),
// Audit 9.1 (t4 line 411): the pulsing amber halo behind the
// hero content — see _HaloPulsante for why it is a BOUNDED pulse,
// not the prototype's literal `infinite` animation.
Positioned(
top: 150,
left: 0,
right: 0,
child: IgnorePointer(
child: Center(child: _HaloPulsante(tokens: tokens)),
),
),
SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 24, 24),
child: Column(
children: [
const Spacer(flex: 2),
// Audit 9.3 (t4 line 415): the schedule pill was missing
// entirely — built from `alarma.tipoProgramacion` (a field
// already on the domain model), no new plumbing.
_PildoraProgramacion(
texto: _resumenProgramacion(context, l10n, alarma),
tokens: tokens,
),
const SizedBox(height: 22),
// Audit 9.4 (t4 line 419): "Lunes, 3 de agosto" between
// the pill and the hero time -- never rendered before.
// Purely additive: a new sibling Text, touching neither
// the pill above nor the hero time below.
Text(
fechaLargaConDiaSemana(
Localizations.localeOf(context).toString(),
DateTime.now(),
),
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
const SizedBox(height: 6),
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
_hora(alarma),
key: const ValueKey('ringing-hero-time'),
// Audit 9.5 (t4 line 417): 88px/w800/ls-4/lh.95 on
// THIS screen only — a local override, not a change
// to the shared heroTime token (EditorHoraInline, the
// alarm editor's hour block, is the other consumer
// and wants height:1, t4 line 379).
style: type.heroTime.copyWith(
letterSpacing: -4,
height: 0.95,
),
),
),
const SizedBox(height: 6),
Text(
localizedAlarmName(l10n, alarma.nombre),
textAlign: TextAlign.center,
style: type.bodyStrong,
),
const SizedBox(height: 22),
ClipRRect(
borderRadius: BorderRadius.circular(_artworkRadio),
child: Image.asset(
'assets/icons/alarmas/alarm_music.png',
width: _artworkLado,
height: _artworkLado,
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,
// Audit 9.7 (t4 line 423): 20px/w800/ls-.3 — this used to
// be `cardTitle` (14.5px/w700), 5.5px and 100 weight
// units under spec for the station name on a full-screen
// ringing surface.
style: type.cardTitle.copyWith(
fontSize: 20,
fontWeight: FontWeight.w800,
letterSpacing: -0.3,
),
),
// 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: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Audit 9.8 (t4 line 428): the POSPONER eyebrow
// lost its snooze icon.
Icon(
Icons.snooze_rounded,
size: 19,
color: tokens.warmCoral,
),
const SizedBox(width: 8),
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(
// Audit 9.11 (t4 line 434): the prototype's Stop is a
// NEUTRAL translucent surface — not the brand cyan
// `colorScheme.primary` this used to render in. It is
// the largest element on a full-screen surface, so
// the wrong colour family was maximally visible.
backgroundColor: Colors.white.withValues(alpha: 0.08),
foregroundColor:
Theme.of(context).colorScheme.onSurface,
side: BorderSide(
color: Colors.white.withValues(alpha: 0.16),
),
minimumSize: const Size.fromHeight(76),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
_stopButtonRadius,
),
),
),
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')}';
/// Audit 9.11 (t4 line 434): the Stop button's radius — doesn't match any of
/// [PluriWaveTokens]'s three named radii (14/18/30), so it stays a local
/// constant here rather than growing the shared token surface for a single
/// call site (mirrors `_ArteEscuchar._radio` in `pantalla_inicio.dart`).
const _stopButtonRadius = 24.0;
/// Audit 9.6 (t4 line 421): the ringing screen's art is 180x180 with a 36
/// corner radius — neither matches [PluriWaveTokens]'s three named radii
/// (14/18/30), so this stays a local constant (same precedent as
/// [_stopButtonRadius] above).
const _artworkLado = 180.0;
const _artworkRadio = 36.0;
/// Audit 9.3: the schedule pill's text, built only from
/// [AlarmaMusical.tipoProgramacion] and the fields it already carries per
/// case (`diasSemana`, `fechaUnica`) — no new domain plumbing. Reuses the
/// `alarmScheduleOnce`/`alarmScheduleWeekdays` ARB keys, which existed
/// already but had no consumer anywhere in the app.
String _resumenProgramacion(
BuildContext context,
AppLocalizations l10n,
AlarmaMusical alarma,
) {
switch (alarma.tipoProgramacion) {
case TipoProgramacionAlarma.diaria:
return l10n.alarmScheduleDaily;
case TipoProgramacionAlarma.unica:
final localeTag = Localizations.localeOf(context).toString();
return l10n.alarmScheduleOnce(
fechaCortaLocalizada(localeTag, alarma.fechaUnica ?? DateTime.now()),
);
case TipoProgramacionAlarma.diasSemana:
final dias = (List<int>.from(alarma.diasSemana)
..sort()).map((d) => _weekdayShort(l10n, d)).join(', ');
return l10n.alarmScheduleWeekdays(dias);
}
}
// Mirrors `pantalla_alarmas.dart`'s private `_weekdayShort` (kept local
// rather than shared/exported: this screen's only other tie to the alarm
// editor is the domain model itself, and duplicating a 7-line switch is
// cheaper than adding a cross-screen import for it).
String _weekdayShort(AppLocalizations l10n, int day) => switch (day) {
DateTime.monday => l10n.weekdayShortMonday,
DateTime.tuesday => l10n.weekdayShortTuesday,
DateTime.wednesday => l10n.weekdayShortWednesday,
DateTime.thursday => l10n.weekdayShortThursday,
DateTime.friday => l10n.weekdayShortFriday,
DateTime.saturday => l10n.weekdayShortSaturday,
DateTime.sunday => l10n.weekdayShortSunday,
_ => '?',
};
/// Schedule pill (audit 9.3, t4 line 415): `alarm` icon + schedule summary
/// on a warmCoral-tinted pill, matching the prototype's
/// `rgba(244,184,96,.16)` fill / `rgba(244,184,96,.45)` border exactly.
class _PildoraProgramacion extends StatelessWidget {
const _PildoraProgramacion({required this.texto, required this.tokens});
final String texto;
final PluriWaveTokens tokens;
@override
Widget build(BuildContext context) {
return DecoratedBox(
key: const ValueKey('ringing-schedule-pill'),
decoration: BoxDecoration(
color: tokens.warmCoral.withValues(alpha: 0.16),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: tokens.warmCoral.withValues(alpha: 0.45)),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.alarm, size: 18, color: tokens.warmCoral),
const SizedBox(width: 6),
Text(
texto,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w800,
letterSpacing: 0.84,
color: tokens.warmCoral,
),
),
],
),
),
);
}
}
/// Pulsing amber halo (audit 9.1, t4 line 411): a 420x420 radial gradient
/// centered behind the hero content, echoing the alarm's warmCoral accent.
///
/// BOUNDED, not infinite: the prototype's CSS is `animation: pw-pulse 2.4s
/// ease-in-out infinite`, but this screen's dismiss guard
/// (`pantalla_alarma_sonando_dismiss_guard_test.dart`, protected — must stay
/// byte-identical to `main`) calls `pumpAndSettle()` after every mount and
/// every interaction. A genuinely infinite `AnimationController.repeat()`
/// anywhere in this widget's subtree would hang every one of those calls
/// forever, with no way to fix it since that file cannot be edited (see
/// `_EstadoSubidaVolumen` above for the same reasoning applied earlier on
/// this exact screen). One grow-and-settle cycle, timed to the prototype's
/// own 2.4s cadence, delivers the same "draws the eye" motion without ever
/// leaving a frame scheduled forever. Respects reduced motion exactly like
/// every other entry animation in this app (`PluriAnimate`).
class _HaloPulsante extends StatelessWidget {
const _HaloPulsante({required this.tokens});
final PluriWaveTokens tokens;
static const _lado = 420.0;
@override
Widget build(BuildContext context) {
final halo = Container(
key: const ValueKey('ringing-pulse-halo'),
width: _lado,
height: _lado,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
tokens.warmCoral.withValues(alpha: 0.2),
tokens.warmCoral.withValues(alpha: 0),
],
stops: const [0, 0.62],
),
),
);
if (MediaQuery.maybeDisableAnimationsOf(context) ?? false) {
return halo;
}
return halo
.animate()
.scaleXY(
begin: 1,
end: 1.08,
duration: 1200.ms,
curve: Curves.easeInOut,
)
.then()
.scaleXY(
begin: 1.08,
end: 1,
duration: 1200.ms,
curve: Curves.easeInOut,
);
}
}
/// 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),
);
// Audit 9.10 (t4 line 433): the prototype stacks a big NUMBER over a
// small "min · habitual" unit — the SAME text-splitting conflict as
// 9.9 (permanently rejected, Engram id 2525): this flat string is
// exactly what the protected dismiss-guard test locates via
// `find.text(l10n.alarmSnoozeOptionLabel(N))` in four places, and
// splitting it into two differently-styled Text nodes would make
// that flat value vanish from the render tree.
//
// Resolved differently here than 9.9: rather than splitting THIS
// string, an entirely SEPARATE small qualifier Text is added
// alongside it (only on the destacado tile) — the original flat
// label stays a single, untouched, unstyled-differently Text node,
// still the exact widget the guard finds and taps. This delivers
// the "habitual" qualifier without the conflict 9.9 hit.
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: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
etiqueta,
Text(
l10n.alarmSnoozeUsualLabel,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w800,
color: tokens.deepViolet.withValues(alpha: 0.75),
),
),
],
),
)
: 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,
),
),
);
}
}