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 createState() => _PantallaAlarmaSonandoState(); } class _PantallaAlarmaSonandoState extends State { /// 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(); _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 _detener() async { if (_salidaEnCurso) return; _salidaEnCurso = true; final alarmas = context.read(); 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 _forzarDetencion() async { if (_salidaEnCurso) return; _salidaEnCurso = true; final alarmas = context.read(); 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 _posponer(int minutos) async { if (_salidaEnCurso) return; _salidaEnCurso = true; final alarmas = context.read(); // 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), 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), // Audit 9.4: the date line goes BELOW the hero time. The // prototype's order is pill (t4:415-416) -> 7:30 at 88px // (t4:417) -> "Lunes, 3 de agosto" at 14px (t4:419). An // earlier pass placed it between the pill and the time // and cited "t4 line 419" for it — that line number is // where the date SITS in the source, which is precisely // why it comes last, not first. 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), 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, ), ), ], ), ), // Issue 3 (feedback-pruebas): t4:427 wraps POSPONER's // eyebrow, the snooze tiles and Stop in a `gap:12` flex // column -- the same 12 on both sides, not the 10/14 pair // this used to carry. const SizedBox(height: 12), _FilaSnoozeFija( alarma: alarma, l10n: l10n, tokens: tokens, onPosponer: _posponer, ), const SizedBox(height: 12), 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.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 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, ), ), ); } }