diff --git a/lib/pantallas/pantalla_alarma_sonando.dart b/lib/pantallas/pantalla_alarma_sonando.dart index 7d8255d..ff3bb48 100644 --- a/lib/pantallas/pantalla_alarma_sonando.dart +++ b/lib/pantallas/pantalla_alarma_sonando.dart @@ -3,6 +3,7 @@ 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'; @@ -199,6 +200,17 @@ class _PantallaAlarmaSonandoState extends State { 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), @@ -218,7 +230,15 @@ class _PantallaAlarmaSonandoState extends State { child: Text( _hora(alarma), key: const ValueKey('ringing-hero-time'), - style: type.heroTime, + // 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), @@ -229,11 +249,11 @@ class _PantallaAlarmaSonandoState extends State { ), const SizedBox(height: 22), ClipRRect( - borderRadius: BorderRadius.circular(tokens.radiusLg), + borderRadius: BorderRadius.circular(_artworkRadio), child: Image.asset( 'assets/icons/alarmas/alarm_music.png', - width: 168, - height: 168, + width: _artworkLado, + height: _artworkLado, fit: BoxFit.cover, errorBuilder: (_, __, ___) => Icon( @@ -280,11 +300,24 @@ class _PantallaAlarmaSonandoState extends State { const Spacer(flex: 3), Align( alignment: Alignment.centerLeft, - child: Text( - l10n.snoozeAction, - style: type.eyebrowLabel.copyWith( - color: tokens.warmCoral, - ), + 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), @@ -377,6 +410,13 @@ String _hora(AlarmaMusical alarma) => /// 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 @@ -458,6 +498,66 @@ class _PildoraProgramacion extends StatelessWidget { } } +/// 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 diff --git a/test/pantallas/pantalla_alarma_sonando_scaffold_test.dart b/test/pantallas/pantalla_alarma_sonando_scaffold_test.dart index e255fac..4e13442 100644 --- a/test/pantallas/pantalla_alarma_sonando_scaffold_test.dart +++ b/test/pantallas/pantalla_alarma_sonando_scaffold_test.dart @@ -146,7 +146,8 @@ void main() { group('WU11 — restyle a pantalla completa', () { testWidgets( - 'el tiempo gigante usa PluriWaveTypography.heroTime envuelto en ' + 'el tiempo gigante usa PluriWaveTypography.heroTime (con un ajuste ' + 'local de letter-spacing/height, audit 9.5) envuelto en ' 'FittedBox(scaleDown)', (tester) async { await _montarPantalla(tester); @@ -155,7 +156,22 @@ void main() { expect(heroFinder, findsOneWidget); final texto = tester.widget(heroFinder); final contexto = tester.element(heroFinder); - expect(texto.style, contexto.pluriType.heroTime); + final base = contexto.pluriType.heroTime; + // Audit 9.5 (t4 line 417): the prototype wants 88px/w800/ls-4/lh.95 + // on THIS screen only. heroTime itself must stay ls-2.0/height 1.0 + // — EditorHoraInline (the alarm editor's hour block) is the other + // consumer and wants height:1 (t4 line 379) — so this is a LOCAL + // copyWith override, not a change to the shared token. + expect(texto.style?.fontSize, base.fontSize); + expect(texto.style?.fontWeight, base.fontWeight); + expect(texto.style?.letterSpacing, -4); + expect(texto.style?.height, 0.95); + expect( + base.letterSpacing, + -2.0, + reason: 'the shared token is unchanged', + ); + expect(base.height, 1.0, reason: 'the shared token is unchanged'); expect( find.ancestor(of: heroFinder, matching: find.byType(FittedBox)), findsOneWidget, diff --git a/test/pantallas/pantalla_alarma_sonando_tier4_test.dart b/test/pantallas/pantalla_alarma_sonando_tier4_test.dart new file mode 100644 index 0000000..3f027d6 --- /dev/null +++ b/test/pantallas/pantalla_alarma_sonando_tier4_test.dart @@ -0,0 +1,189 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/estado/estado_alarmas.dart'; +import 'package:pluriwave/estado/estado_radio.dart'; +import 'package:pluriwave/l10n/gen/app_localizations.dart'; +import 'package:pluriwave/modelos/alarma_musical.dart'; +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:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../helpers/fakes.dart'; +import '../helpers/fakes_alarmas.dart'; + +/// Tier 4 visual fidelity, ringing screen: 9.1 (pulsing amber halo), 9.6 +/// (art geometry), 9.8 (snooze eyebrow icon). Item 9.5 (hero time metrics) +/// is covered in `pantalla_alarma_sonando_scaffold_test.dart`, which already +/// owned the pre-existing `heroTime`-equality assertion that item revises. +/// +/// 9.1 is the one with real hazard: the prototype's halo is a literal CSS +/// `animation: pw-pulse 2.4s ease-in-out infinite`. This screen's dismiss +/// guard (`pantalla_alarma_sonando_dismiss_guard_test.dart`, protected, +/// byte-identical to `main`) calls `pumpAndSettle()` after every mount and +/// every interaction — an actually-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 touched. The +/// halo below is a BOUNDED pulse (one grow/settle cycle, no `.repeat()`) — +/// genuine animated motion, but one that always finishes. +Future _montarPantalla( + WidgetTester tester, { + bool disableAnimations = false, +}) async { + tester.view.physicalSize = const Size(1440, 3200); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final audio = FakeServicioAudio(); + audio.emitirEstado(EstadoReproduccion.reproduciendo); + final radio = EstadoRadio( + audio: audio, + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + servicioGrabacion: FakeServicioGrabacionRadioInactiva(), + iniciarAutomaticamente: false, + ); + addTearDown(radio.dispose); + + final android = FakePuertoAlarmasAndroid(); + final estadoAlarmas = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => DateTime(2026, 6, 11, 7, 0)), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estadoAlarmas.dispose); + addTearDown(android.dispose); + await estadoAlarmas.guardarAlarma( + const AlarmaMusical( + id: 'tier4-1', + nombre: 'Despertar', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + emisora: Emisora( + uuid: 'e1', + nombre: 'Radio Uno', + url: 'https://radio.example/stream', + ), + ), + ); + + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: radio), + ChangeNotifierProvider.value(value: estadoAlarmas), + ], + child: MaterialApp( + locale: const Locale('es'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: PluriWaveTheme.dark(), + builder: + (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(disableAnimations: disableAnimations), + child: child!, + ), + home: const SizedBox.shrink(), + ), + ), + ); + final navigator = tester.state(find.byType(Navigator)); + unawaited( + navigator.push( + MaterialPageRoute( + builder: + (_) => PantallaAlarmaSonando(alarma: estadoAlarmas.alarmas.single), + fullscreenDialog: true, + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); +} + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + testWidgets( + 'visual fidelity (audit 9.1): a pulsing amber halo renders behind the ' + 'hero content, and settling it NEVER hangs (protects the dismiss-guard ' + "test's repeated pumpAndSettle calls)", + (tester) async { + await _montarPantalla(tester); + + expect(find.byKey(const ValueKey('ringing-pulse-halo')), findsOneWidget); + + // The critical regression guard: this must COMPLETE. An infinite + // AnimationController.repeat() here would hang every pumpAndSettle() + // call in the protected dismiss-guard test forever. + await tester.pumpAndSettle(); + }, + ); + + testWidgets( + 'visual fidelity (audit 9.1): the halo skips its Animate wrapper under ' + 'reduced motion (matches every other entry animation in this app)', + (tester) async { + await _montarPantalla(tester, disableAnimations: true); + + expect(find.byType(Animate), findsNothing); + expect(find.byKey(const ValueKey('ringing-pulse-halo')), findsOneWidget); + }, + ); + + testWidgets( + 'visual fidelity (audit 9.6): the ringing art is 180 square with a 36 ' + 'corner radius, not 168/30', + (tester) async { + await _montarPantalla(tester); + + final art = tester + .widgetList(find.byType(Image)) + .where((img) => img.width == 180 && img.height == 180); + expect( + art, + isNotEmpty, + reason: 'prototype t4 line 421 sizes the ringing-screen art at 180', + ); + + final clip = tester.widget( + find.ancestor( + of: find.byWidgetPredicate( + (w) => w is Image && w.width == 180 && w.height == 180, + ), + matching: find.byType(ClipRRect), + ), + ); + expect( + (clip.borderRadius as BorderRadius).topLeft, + const Radius.circular(36), + ); + }, + ); + + testWidgets( + 'visual fidelity (audit 9.8): the POSPONER eyebrow keeps its snooze ' + 'icon at 19px warmCoral', + (tester) async { + await _montarPantalla(tester); + + final icon = tester.widget(find.byIcon(Icons.snooze_rounded)); + expect(icon.size, 19); + expect(icon.color, const Color(0xFFF4B860)); + }, + ); +}