From e1732af2224584efde1210d3141c69d44d5b1503 Mon Sep 17 00:00:00 2001 From: freetlab Date: Wed, 29 Jul 2026 12:00:59 +0200 Subject: [PATCH] feat(alarma-sonando): restyle ringing screen, drop live countdown label 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. --- lib/pantallas/pantalla_alarma_sonando.dart | 302 ++++++++++++++---- openspec/changes/rediseno-funcional/tasks.md | 50 ++- ...pantalla_alarma_sonando_scaffold_test.dart | 40 ++- .../pantalla_alarma_sonando_test.dart | 188 +++++++++-- 4 files changed, 478 insertions(+), 102 deletions(-) diff --git a/lib/pantallas/pantalla_alarma_sonando.dart b/lib/pantallas/pantalla_alarma_sonando.dart index 9bf1c28..112dd0f 100644 --- a/lib/pantallas/pantalla_alarma_sonando.dart +++ b/lib/pantallas/pantalla_alarma_sonando.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -11,7 +12,6 @@ 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 { @@ -153,13 +153,6 @@ class _PantallaAlarmaSonandoState extends State { } } - List _opcionesSnooze() { - final opciones = {3, 5, 10}; - final propio = widget.alarma.snoozeMinutos; - if (propio > 0) opciones.add(propio); - return opciones.toList()..sort(); - } - @override void dispose() { _alarmas.removeListener(_alReconciliarFinExterno); @@ -195,39 +188,53 @@ class _PantallaAlarmaSonandoState extends State { 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: 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), + body: Stack( + fit: StackFit.expand, + children: [ + _FondoArteDifuminado(tokens: tokens), + SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 20, 24, 24), 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 Spacer(flex: 2), + FittedBox( + fit: BoxFit.scaleDown, + child: Text( + _hora(alarma), + key: const ValueKey('ringing-hero-time'), + style: type.heroTime, ), ), - const SizedBox(height: 8), + const SizedBox(height: 6), Text( localizedAlarmName(l10n, alarma.nombre), textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleLarge, + style: type.bodyStrong, ), - const SizedBox(height: 8), + 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 @@ -237,31 +244,54 @@ class _PantallaAlarmaSonandoState extends State { ? localizedStationName(l10n, alarma.emisora!.nombre) : l10n.alarmRingingNotificationTitle, textAlign: TextAlign.center, + style: type.cardTitle, ), - const SizedBox(height: 22), - Text( - l10n.snoozeAction, - style: Theme.of(context).textTheme.labelLarge, + // 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: 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: 10), + _FilaSnoozeFija( + alarma: alarma, + l10n: l10n, + tokens: tokens, + onPosponer: _posponer, ), const SizedBox(height: 14), - FilledButton.icon( - onPressed: _detener, - icon: const Icon(Icons.stop_rounded), - label: Text(l10n.stopAlarmAction), + 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), @@ -271,7 +301,7 @@ class _PantallaAlarmaSonandoState extends State { ), ).pluriFadeIn(context), ), - ), + ], ), ); } @@ -310,3 +340,167 @@ class _PantallaAlarmaSonandoState extends State { 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 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, + ), + ), + ); + } +} diff --git a/openspec/changes/rediseno-funcional/tasks.md b/openspec/changes/rediseno-funcional/tasks.md index 4e763a4..925be72 100644 --- a/openspec/changes/rediseno-funcional/tasks.md +++ b/openspec/changes/rediseno-funcional/tasks.md @@ -680,19 +680,45 @@ Banner Survive the Restyle > **Hard rule, restated:** if the restyle appears to require changing the dismiss-guard test, the task is to STOP > and escalate — not to edit the test. -- [ ] 11.1 RED — update the two non-guard test files for the full-bleed blurred-art layout, giant 88px time +- [x] 11.1 RED — update the two non-guard test files for the full-bleed blurred-art layout, giant 88px time (`heroTime`, wrapped in `FittedBox(fit: BoxFit.scaleDown)` per the text-scaling rule), 3 fixed snooze tiles - (3/5/10 min, 10 highlighted), full-width stop pill. -- [ ] 11.2 RED — the status label renders "Subiendo volumen" (or localized equivalent) with no accompanying - numeric value that changes over time. -- [ ] 11.3 GREEN — restyle to a full-bleed art background; replace the `Wrap` of snooze chips with 3 fixed tiles; - replace the glass-card container. -- [ ] 11.4 GREEN — keep the status label static, sourced only from `widget.alarma` (unchanged from the existing - documented constraint); preserve the force-stop retry banner in the new layout. -- [ ] 11.5 REFACTOR — run `pantalla_alarma_sonando_dismiss_guard_test.dart` **unchanged**; if it fails, STOP — do - not edit it, escalate instead. -- [ ] 11.6 Verify — force-stop banner still appears on a simulated stop failure; dismiss-guard test file diff is - empty. + (3/5/10 min, 10 highlighted), full-width stop pill. **Correction found at apply time**: the pre-existing + "3/5/10 mas el personalizado" test (snoozeMinutos=7 growing a 4th button) is exactly the behavior this WU + replaces — rewrote it in place (not deleted) to assert the new "always exactly 3, no 4th tile" contract, + plus 2 new tests pinning which tile is `FilledButton` (highlighted) vs `OutlinedButton`. +- [x] 11.2 RED — the status label renders "Subiendo volumen" (or localized equivalent) with no accompanying + numeric value that changes over time. New ARB key `alarmVolumeRisingStatus` carries NO placeholder at all + (unlike `alarmFadeInSummary`, which has `{seconds}`) — by construction this label can never grow a live + counter without a deliberate key change. Gated on `alarma.fadeInSegundos > 0`: the existing `_montarPantalla` + test helper in `pantalla_alarma_sonando_test.dart` already carried an unused `fadeInSegundos` parameter + defaulting to 0, confirming this gate was anticipated ahead of this WU. +- [x] 11.3 GREEN — restyle to a full-bleed art background; replace the `Wrap` of snooze chips with 3 fixed tiles; + replace the glass-card container. No per-station artwork exists in this codebase (`Emisora.favicon` is a + network URL — rendering it via `Image.network` in a widget test hangs/throws without a mocked + `HttpClient`), so the existing bundled `alarm_music.png` asset is reused, blurred (`ImageFiltered`, + sigma 44) and stretched — decorative, not spec-tested. +- [x] 11.4 GREEN — keep the status label static, sourced only from `widget.alarma` (unchanged from the existing + documented constraint); preserve the force-stop retry banner in the new layout. `_bannerFalloDetencion` is + BYTE-IDENTICAL to its pre-WU11 form (same l10n keys, same widget structure) — only repositioned, never + rewritten, so every existing force-stop test (SS-3a/SS-3b/SS-3c/RES-2) keeps passing unmodified. **Design + decision, not specified by any ADR (WU11 has none)**: the "Subiendo volumen" status dot is intentionally + NOT animated/pulsing — an `AnimationController.repeat()` here would reintroduce the exact + "`pumpAndSettle()` never terminates" hazard WU5 documented for `VisualizadorAudio`. +- [x] 11.5 REFACTOR — ran `pantalla_alarma_sonando_dismiss_guard_test.dart` **unchanged**: all 8 cases green, + byte-identical, no edit needed or made. +- [x] 11.6 Verify — force-stop banner still appears on a simulated stop failure (all 3 force-stop scenarios green, + unmodified); dismiss-guard test file diff is empty against BOTH `HEAD` and `main`. + +**`size:exception` recorded.** Realized: **530 changed lines** (440+/90-) across 3 files against the 200-300 +forecast — `lib/` production code alone is 302 lines (248+/54-), essentially at the top edge of the forecast band +by itself; the 2 modified test files add 228 lines, the same "a strict-TDD commit carries its test files" pattern +as every prior WU (Engram `reference/estimating-strict-tdd-diffs`, id 2514) — this WU touches no ARB keys beyond +the 1 new `alarmVolumeRisingStatus` string, so no 13-file l10n/gen inflation this time; test growth alone accounts +for the overage. Not pre-flagged for exception in the forecast table (unlike WU10), but the same "one cohesive +visual+behavioral restyle to one safety-critical screen" reasoning applies: splitting the background/snooze-tiles/ +stop-pill/status-label changes into separate commits would leave an inconsistent intermediate UI on the ringing +screen specifically — the one screen in this whole branch where an inconsistent intermediate state is least +acceptable. ## WU13 — Ecualizador settings screen (5-band restyle) diff --git a/test/pantallas/pantalla_alarma_sonando_scaffold_test.dart b/test/pantallas/pantalla_alarma_sonando_scaffold_test.dart index 732c953..e255fac 100644 --- a/test/pantallas/pantalla_alarma_sonando_scaffold_test.dart +++ b/test/pantallas/pantalla_alarma_sonando_scaffold_test.dart @@ -11,6 +11,8 @@ import 'package:pluriwave/modelos/emisora.dart'; import 'package:pluriwave/pantallas/pantalla_alarma_sonando.dart'; import 'package:pluriwave/servicios/servicio_alarmas.dart'; import 'package:pluriwave/servicios/servicio_audio.dart'; +import 'package:pluriwave/tema/pluriwave_theme.dart'; +import 'package:pluriwave/widgets/pluri_glass_surface.dart'; import 'package:pluriwave/widgets/pluri_wave_scaffold.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -89,9 +91,7 @@ Future _montarPantalla( navigator.push( MaterialPageRoute( builder: - (_) => PantallaAlarmaSonando( - alarma: estadoAlarmas.alarmas.single, - ), + (_) => PantallaAlarmaSonando(alarma: estadoAlarmas.alarmas.single), fullscreenDialog: true, ), ), @@ -143,4 +143,38 @@ void main() { expect(find.text(l10n.stopAlarmAction), findsOneWidget); }, ); + + group('WU11 — restyle a pantalla completa', () { + testWidgets( + 'el tiempo gigante usa PluriWaveTypography.heroTime envuelto en ' + 'FittedBox(scaleDown)', + (tester) async { + await _montarPantalla(tester); + + final heroFinder = find.byKey(const ValueKey('ringing-hero-time')); + expect(heroFinder, findsOneWidget); + final texto = tester.widget(heroFinder); + final contexto = tester.element(heroFinder); + expect(texto.style, contexto.pluriType.heroTime); + expect( + find.ancestor(of: heroFinder, matching: find.byType(FittedBox)), + findsOneWidget, + ); + }, + ); + + testWidgets( + 'el fondo es arte difuminado a pantalla completa; el contenedor ' + 'glass-card anterior desaparece', + (tester) async { + await _montarPantalla(tester); + + expect( + find.byKey(const ValueKey('ringing-background-art')), + findsOneWidget, + ); + expect(find.byType(PluriGlassSurface), findsNothing); + }, + ); + }); } diff --git a/test/pantallas/pantalla_alarma_sonando_test.dart b/test/pantallas/pantalla_alarma_sonando_test.dart index 59a4202..1cf8b56 100644 --- a/test/pantallas/pantalla_alarma_sonando_test.dart +++ b/test/pantallas/pantalla_alarma_sonando_test.dart @@ -107,8 +107,7 @@ Future<_Entorno> _montarPantalla( navigator.push( MaterialPageRoute( builder: - (_) => - PantallaAlarmaSonando(alarma: estadoAlarmas.alarmas.single), + (_) => PantallaAlarmaSonando(alarma: estadoAlarmas.alarmas.single), fullscreenDialog: true, ), ), @@ -124,29 +123,153 @@ void main() { SharedPreferences.setMockInitialValues({}); }); - testWidgets( - 'muestra botones de posponer 3/5/10 mas el personalizado (S2-R1-A/C)', - (tester) async { - await _montarPantalla(tester, snoozeMinutos: 7); + group('WU11 — 3 tiles de posponer fijos (3/5/10 min)', () { + testWidgets( + 'siempre son exactamente 3 tiles fijos, incluso con un snoozeMinutos ' + 'personalizado que no es 3/5/10 (S2-R1-A/C, restilizado)', + (tester) async { + // WU11 correction: the ringing screen's snooze row is no longer a + // variable-length Wrap that grows for a custom value — the mockup's + // "3 fixed tiles" replaces it. A custom snoozeMinutos (7 here, same + // fixture as before WU11) still configures the ALARM's own default + // elsewhere (the editor), but no longer grows a 4th tile on this + // screen specifically. + await _montarPantalla(tester, snoozeMinutos: 7); - expect(find.text(l10n.alarmSnoozeOptionLabel(3)), findsOneWidget); - expect(find.text(l10n.alarmSnoozeOptionLabel(5)), findsOneWidget); - expect(find.text(l10n.alarmSnoozeOptionLabel(7)), findsOneWidget); - expect(find.text(l10n.alarmSnoozeOptionLabel(10)), findsOneWidget); - expect(find.text(l10n.stopAlarmAction), findsOneWidget); - }, - ); + expect(find.text(l10n.alarmSnoozeOptionLabel(3)), findsOneWidget); + expect(find.text(l10n.alarmSnoozeOptionLabel(5)), findsOneWidget); + expect(find.text(l10n.alarmSnoozeOptionLabel(10)), findsOneWidget); + expect(find.text(l10n.alarmSnoozeOptionLabel(7)), findsNothing); + expect(find.text(l10n.stopAlarmAction), findsOneWidget); + }, + ); - testWidgets( - 'no duplica el boton cuando snoozeMinutos coincide con una opcion fija', - (tester) async { - await _montarPantalla(tester, snoozeMinutos: 5); + testWidgets( + 'sigue habiendo exactamente 3 tiles cuando snoozeMinutos coincide con ' + 'una opcion fija', + (tester) async { + await _montarPantalla(tester, snoozeMinutos: 5); - expect(find.text(l10n.alarmSnoozeOptionLabel(3)), findsOneWidget); - expect(find.text(l10n.alarmSnoozeOptionLabel(5)), findsOneWidget); - expect(find.text(l10n.alarmSnoozeOptionLabel(10)), findsOneWidget); - }, - ); + expect(find.text(l10n.alarmSnoozeOptionLabel(3)), findsOneWidget); + expect(find.text(l10n.alarmSnoozeOptionLabel(5)), findsOneWidget); + expect(find.text(l10n.alarmSnoozeOptionLabel(10)), findsOneWidget); + }, + ); + + testWidgets( + 'el tile que coincide con snoozeMinutos es el destacado (FilledButton); ' + 'los otros dos son OutlinedButton', + (tester) async { + await _montarPantalla(tester, snoozeMinutos: 5); + + expect( + find.ancestor( + of: find.text(l10n.alarmSnoozeOptionLabel(5)), + matching: find.byType(FilledButton), + ), + findsOneWidget, + ); + expect( + find.ancestor( + of: find.text(l10n.alarmSnoozeOptionLabel(3)), + matching: find.byType(OutlinedButton), + ), + findsOneWidget, + ); + expect( + find.ancestor( + of: find.text(l10n.alarmSnoozeOptionLabel(10)), + matching: find.byType(OutlinedButton), + ), + findsOneWidget, + ); + }, + ); + + testWidgets( + 'design decision: cuando snoozeMinutos no es 3/5/10, el destacado ' + 'por defecto es 10 (el valor "habitual" del mockup)', + (tester) async { + await _montarPantalla(tester, snoozeMinutos: 7); + + expect( + find.ancestor( + of: find.text(l10n.alarmSnoozeOptionLabel(10)), + matching: find.byType(FilledButton), + ), + findsOneWidget, + ); + expect( + find.ancestor( + of: find.text(l10n.alarmSnoozeOptionLabel(3)), + matching: find.byType(OutlinedButton), + ), + findsOneWidget, + ); + expect( + find.ancestor( + of: find.text(l10n.alarmSnoozeOptionLabel(5)), + matching: find.byType(OutlinedButton), + ), + findsOneWidget, + ); + }, + ); + }); + + group('WU11 — pildora de Detener a todo lo ancho', () { + testWidgets('el boton de Detener ocupa todo el ancho disponible', ( + tester, + ) async { + await _montarPantalla(tester); + + // The test viewport is 1440 logical px wide (physicalSize / ratio set + // in `_montarPantalla`); a normal wrap-content button would be well + // under 300px. This is a "clearly full-bleed, not auto-sized" + // assertion rather than a pixel-perfect one — the exact horizontal + // padding is a cosmetic layout detail, not a contract. + final tamano = tester.getSize( + find.byKey(const ValueKey('ringing-stop-button')), + ); + expect(tamano.width, greaterThan(1000)); + }); + }); + + group('WU11 — estado estatico de subida de volumen (resolucion 4, sin ' + 'contador en vivo)', () { + testWidgets( + 'con fadeInSegundos > 0 muestra la etiqueta estatica, sin sufijo ' + 'numerico', + (tester) async { + await _montarPantalla(tester, fadeInSegundos: 20); + + final texto = tester.widget( + find.descendant( + of: find.byKey(const ValueKey('estado-subida-volumen')), + matching: find.byType(Text), + ), + ); + // Exact-equality (not `contains`) is what proves there is no + // interpolated/changing suffix at all — `alarmVolumeRisingStatus` + // carries no ARB placeholder, so this can never silently grow a + // live counter later without a deliberate key change. + expect(texto.data, l10n.alarmVolumeRisingStatus); + }, + ); + + testWidgets( + 'con fadeInSegundos == 0 (por defecto) no muestra la etiqueta en ' + 'absoluto', + (tester) async { + await _montarPantalla(tester); + + expect( + find.byKey(const ValueKey('estado-subida-volumen')), + findsNothing, + ); + }, + ); + }); testWidgets( 'posponer 5 min pospone la alarma y cierra la pantalla (S2-R1-B)', @@ -196,19 +319,18 @@ void main() { }, ); - testWidgets( - 'detener confirmado no muestra el banner de fallo (SS-3c)', - (tester) async { - final entorno = await _montarPantalla(tester); + testWidgets('detener confirmado no muestra el banner de fallo (SS-3c)', ( + tester, + ) async { + final entorno = await _montarPantalla(tester); - await tester.tap(find.text(l10n.stopAlarmAction)); - await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.stopAlarmAction)); + await tester.pumpAndSettle(); - expect(find.byType(PantallaAlarmaSonando), findsNothing); - expect(find.text(l10n.alarmStopFailedMessage), findsNothing); - expect(entorno.android.detencionesActivas, isNotEmpty); - }, - ); + expect(find.byType(PantallaAlarmaSonando), findsNothing); + expect(find.text(l10n.alarmStopFailedMessage), findsNothing); + expect(entorno.android.detencionesActivas, isNotEmpty); + }); testWidgets( 'forzar detencion: invocacion superpuesta es no-op y tras un fallo '