diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index bfa68c3..6105025 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -701,6 +701,8 @@ "notPlaying": "Not playing", "oneTimeOption": "Once", "pausePlaybackTooltip": "Pause playback", + "playerQualityChangeAction": "Change", + "playerToolEqLabel": "Own EQ", "qualityOriginal": "Original quality: {quality}", "@qualityOriginal": { "placeholders": { diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 52d320b..896beca 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -657,6 +657,8 @@ "notPlaying": "No está reproduciendo", "oneTimeOption": "Una vez", "pausePlaybackTooltip": "Pausar reproducción", + "playerQualityChangeAction": "Cambiar", + "playerToolEqLabel": "EQ propio", "qualityOriginal": "Calidad original: {quality}", "@qualityOriginal": {"placeholders": {"quality": {}}}, "qualityUnknown": "Calidad no informada", diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 3188235..61575eb 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -2480,6 +2480,18 @@ abstract class AppLocalizations { /// **'Pausar reproducción'** String get pausePlaybackTooltip; + /// No description provided for @playerQualityChangeAction. + /// + /// In es, this message translates to: + /// **'Cambiar'** + String get playerQualityChangeAction; + + /// No description provided for @playerToolEqLabel. + /// + /// In es, this message translates to: + /// **'EQ propio'** + String get playerToolEqLabel; + /// No description provided for @qualityOriginal. /// /// In es, this message translates to: diff --git a/lib/l10n/gen/app_localizations_ar.dart b/lib/l10n/gen/app_localizations_ar.dart index 374fff2..5f7cbe6 100644 --- a/lib/l10n/gen/app_localizations_ar.dart +++ b/lib/l10n/gen/app_localizations_ar.dart @@ -1360,6 +1360,12 @@ class AppLocalizationsAr extends AppLocalizations { @override String get pausePlaybackTooltip => 'إيقاف مؤقت'; + @override + String get playerQualityChangeAction => 'Cambiar'; + + @override + String get playerToolEqLabel => 'EQ propio'; + @override String qualityOriginal(Object quality) { return 'الجودة الأصلية: $quality'; diff --git a/lib/l10n/gen/app_localizations_bn.dart b/lib/l10n/gen/app_localizations_bn.dart index 1b34647..ee0cf7c 100644 --- a/lib/l10n/gen/app_localizations_bn.dart +++ b/lib/l10n/gen/app_localizations_bn.dart @@ -1369,6 +1369,12 @@ class AppLocalizationsBn extends AppLocalizations { @override String get pausePlaybackTooltip => 'প্লেব্যাক বিরতি'; + @override + String get playerQualityChangeAction => 'Cambiar'; + + @override + String get playerToolEqLabel => 'EQ propio'; + @override String qualityOriginal(Object quality) { return 'মূল মান: $quality'; diff --git a/lib/l10n/gen/app_localizations_de.dart b/lib/l10n/gen/app_localizations_de.dart index 4d0946f..b3513cb 100644 --- a/lib/l10n/gen/app_localizations_de.dart +++ b/lib/l10n/gen/app_localizations_de.dart @@ -1377,6 +1377,12 @@ class AppLocalizationsDe extends AppLocalizations { @override String get pausePlaybackTooltip => 'Wiedergabe pausieren'; + @override + String get playerQualityChangeAction => 'Cambiar'; + + @override + String get playerToolEqLabel => 'EQ propio'; + @override String qualityOriginal(Object quality) { return 'Originalqualität: $quality'; diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index 3fefb0f..bf27499 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -1361,6 +1361,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get pausePlaybackTooltip => 'Pause playback'; + @override + String get playerQualityChangeAction => 'Change'; + + @override + String get playerToolEqLabel => 'Own EQ'; + @override String qualityOriginal(Object quality) { return 'Original quality: $quality'; diff --git a/lib/l10n/gen/app_localizations_es.dart b/lib/l10n/gen/app_localizations_es.dart index 43965f1..68a1f68 100644 --- a/lib/l10n/gen/app_localizations_es.dart +++ b/lib/l10n/gen/app_localizations_es.dart @@ -1372,6 +1372,12 @@ class AppLocalizationsEs extends AppLocalizations { @override String get pausePlaybackTooltip => 'Pausar reproducción'; + @override + String get playerQualityChangeAction => 'Cambiar'; + + @override + String get playerToolEqLabel => 'EQ propio'; + @override String qualityOriginal(Object quality) { return 'Calidad original: $quality'; diff --git a/lib/l10n/gen/app_localizations_fr.dart b/lib/l10n/gen/app_localizations_fr.dart index 8685b00..7d3776c 100644 --- a/lib/l10n/gen/app_localizations_fr.dart +++ b/lib/l10n/gen/app_localizations_fr.dart @@ -1382,6 +1382,12 @@ class AppLocalizationsFr extends AppLocalizations { @override String get pausePlaybackTooltip => 'Mettre en pause'; + @override + String get playerQualityChangeAction => 'Cambiar'; + + @override + String get playerToolEqLabel => 'EQ propio'; + @override String qualityOriginal(Object quality) { return 'Qualité d’origine : $quality'; diff --git a/lib/l10n/gen/app_localizations_hi.dart b/lib/l10n/gen/app_localizations_hi.dart index b4319a9..0e5ec22 100644 --- a/lib/l10n/gen/app_localizations_hi.dart +++ b/lib/l10n/gen/app_localizations_hi.dart @@ -1367,6 +1367,12 @@ class AppLocalizationsHi extends AppLocalizations { @override String get pausePlaybackTooltip => 'प्लेबैक रोकें'; + @override + String get playerQualityChangeAction => 'Cambiar'; + + @override + String get playerToolEqLabel => 'EQ propio'; + @override String qualityOriginal(Object quality) { return 'मूल गुणवत्ता: $quality'; diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index 5f30325..645d2dd 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -1373,6 +1373,12 @@ class AppLocalizationsId extends AppLocalizations { @override String get pausePlaybackTooltip => 'Jeda pemutaran'; + @override + String get playerQualityChangeAction => 'Cambiar'; + + @override + String get playerToolEqLabel => 'EQ propio'; + @override String qualityOriginal(Object quality) { return 'Kualitas asli: $quality'; diff --git a/lib/l10n/gen/app_localizations_it.dart b/lib/l10n/gen/app_localizations_it.dart index 2fd1497..136144f 100644 --- a/lib/l10n/gen/app_localizations_it.dart +++ b/lib/l10n/gen/app_localizations_it.dart @@ -1377,6 +1377,12 @@ class AppLocalizationsIt extends AppLocalizations { @override String get pausePlaybackTooltip => 'Pausa riproduzione'; + @override + String get playerQualityChangeAction => 'Cambiar'; + + @override + String get playerToolEqLabel => 'EQ propio'; + @override String qualityOriginal(Object quality) { return 'Qualità originale: $quality'; diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 34e77fb..5a13d4a 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -1329,6 +1329,12 @@ class AppLocalizationsJa extends AppLocalizations { @override String get pausePlaybackTooltip => '再生を一時停止'; + @override + String get playerQualityChangeAction => 'Cambiar'; + + @override + String get playerToolEqLabel => 'EQ propio'; + @override String qualityOriginal(Object quality) { return '元の音質: $quality'; diff --git a/lib/l10n/gen/app_localizations_pt.dart b/lib/l10n/gen/app_localizations_pt.dart index d82330a..492e5e9 100644 --- a/lib/l10n/gen/app_localizations_pt.dart +++ b/lib/l10n/gen/app_localizations_pt.dart @@ -1369,6 +1369,12 @@ class AppLocalizationsPt extends AppLocalizations { @override String get pausePlaybackTooltip => 'Pausar reprodução'; + @override + String get playerQualityChangeAction => 'Cambiar'; + + @override + String get playerToolEqLabel => 'EQ propio'; + @override String qualityOriginal(Object quality) { return 'Qualidade original: $quality'; diff --git a/lib/l10n/gen/app_localizations_ru.dart b/lib/l10n/gen/app_localizations_ru.dart index f6c7298..dd41836 100644 --- a/lib/l10n/gen/app_localizations_ru.dart +++ b/lib/l10n/gen/app_localizations_ru.dart @@ -1373,6 +1373,12 @@ class AppLocalizationsRu extends AppLocalizations { @override String get pausePlaybackTooltip => 'Пауза'; + @override + String get playerQualityChangeAction => 'Cambiar'; + + @override + String get playerToolEqLabel => 'EQ propio'; + @override String qualityOriginal(Object quality) { return 'Исходное качество: $quality'; diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index e07daf5..8419e1c 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -1324,6 +1324,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get pausePlaybackTooltip => '暂停播放'; + @override + String get playerQualityChangeAction => 'Cambiar'; + + @override + String get playerToolEqLabel => 'EQ propio'; + @override String qualityOriginal(Object quality) { return '原始质量:$quality'; diff --git a/lib/pantallas/pantalla_reproductor.dart b/lib/pantallas/pantalla_reproductor.dart index cc010ca..3f46e5e 100644 --- a/lib/pantallas/pantalla_reproductor.dart +++ b/lib/pantallas/pantalla_reproductor.dart @@ -1,6 +1,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:share_plus/share_plus.dart' show Share; import 'package:shimmer/shimmer.dart'; import '../estado/estado_ecualizador.dart'; @@ -12,14 +13,29 @@ import '../servicios/servicio_audio.dart'; import '../servicios/servicio_timer.dart'; import '../tema/pluri_animate.dart'; import '../tema/pluriwave_theme.dart'; +import '../widgets/ecualizador_widget.dart'; import '../widgets/pluri_glass_surface.dart'; -import '../widgets/pluri_wave_scaffold.dart'; +import '../widgets/pluri_premium_widgets.dart'; +import '../widgets/pluri_push_scaffold.dart'; import '../widgets/visualizador_audio.dart'; +/// WU14: restructured onto [PluriPushScaffold] (design ADR-2) — this screen +/// is the ONE documented consumer of `titleOverride` (the centered live/not +/// -playing pill) and of a non-default `leadingIcon` +/// (`keyboard_arrow_down_rounded`, this screen dismisses down, not back). +/// Square art (was circular), a single subtitle line, a quality row, and a +/// 4-tile tool tray (EQ propio / Grabar / sleep timer / Compartir) replace +/// the old info-chip row, always-expanded recording panel and standalone +/// sleep-timer button. The per-station EQ sheet reuses [EcualizadorWidget] +/// by its exact runtime type (design ADR-5) — no second editor. class PantallaReproductor extends StatefulWidget { final Emisora emisora; - const PantallaReproductor({super.key, required this.emisora}); + /// Injected for tests (mirrors `pantalla_grabaciones.dart`'s WU15 + /// `compartir` pattern) — defaults to the real `share_plus` call. + final Future Function(String texto)? compartir; + + const PantallaReproductor({super.key, required this.emisora, this.compartir}); static Future abrir(BuildContext context, Emisora emisora) { return Navigator.push( @@ -46,18 +62,21 @@ class PantallaReproductor extends StatefulWidget { State createState() => _PantallaReproductorState(); } -class _PantallaReproductorState extends State - with SingleTickerProviderStateMixin { - late AnimationController _pulseController; +class _PantallaReproductorState extends State { + late final Future Function(String) _compartir = + widget.compartir ?? (texto) => Share.share(texto); @override void initState() { super.initState(); - _pulseController = AnimationController( - vsync: this, - duration: const Duration(seconds: 2), - ); - _iniciarReproduccion(); + // Bugfix (surfaced by this screen's first-ever test coverage, WU14): + // EstadoRadio.reproducir() calls notifyListeners() synchronously before + // its first await when no recording is active, which previously threw + // "setState() or markNeedsBuild() called during build" the instant this + // screen mounted with a fresh Provider tree (e.g. every widget test that + // pumps this screen for the first time). Deferring to the post-frame + // callback keeps the exact same effect one frame later, outside build. + WidgetsBinding.instance.addPostFrameCallback((_) => _iniciarReproduccion()); } Future _iniciarReproduccion() async { @@ -67,12 +86,6 @@ class _PantallaReproductorState extends State } } - @override - void dispose() { - _pulseController.dispose(); - super.dispose(); - } - @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -87,46 +100,29 @@ class _PantallaReproductorState extends State (e) => e.uuid == emisoraActiva.uuid, ); - return PluriWaveScaffold( - appBar: AppBar( - backgroundColor: Colors.transparent, - elevation: 0, - leading: IconButton( - icon: const Icon(Icons.keyboard_arrow_down_rounded, size: 32), - tooltip: l10n.closeAction, - onPressed: () => Navigator.pop(context), - ), - actions: [ - IconButton( - icon: Icon( - eq.activo ? Icons.equalizer_rounded : Icons.equalizer_outlined, - color: eq.activo ? tokens.warmCoral : null, - ), - tooltip: eq.activo ? l10n.equalizerDisable : l10n.equalizerEnable, - onPressed: () => eq.cambiarActivo(!eq.activo), - ), - IconButton( - icon: Icon( - esFavorito - ? Icons.favorite_rounded - : Icons.favorite_outline_rounded, - color: esFavorito ? theme.colorScheme.error : null, - ), - tooltip: - esFavorito - ? l10n.favoritesRemoveTooltip - : l10n.favoritesAddTooltip, - onPressed: () async => estado.toggleFavorito(emisoraActiva), - ), - ], + return PluriPushScaffold( + title: emisoraActiva.nombre, + leadingIcon: Icons.keyboard_arrow_down_rounded, + titleOverride: StreamBuilder( + stream: estado.estadoStream, + builder: (context, snapshot) { + final enVivo = snapshot.data == EstadoReproduccion.reproduciendo; + return PluriStatusPill( + icon: + enVivo + ? Icons.podcasts_rounded + : Icons.pause_circle_outline_rounded, + label: enVivo ? l10n.liveNow : l10n.notPlaying, + accent: enVivo ? tokens.liveGreen : null, + ); + }, ), body: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), child: Column( children: [ - const SizedBox(height: 8), - _WaveHero( + _ArteReproductor( emisora: emisoraActiva, estadoStream: estado.estadoStream, ).pluriScaleIn( @@ -145,23 +141,15 @@ class _PantallaReproductorState extends State maxLines: 2, overflow: TextOverflow.ellipsis, ).pluriFadeIn(context, delay: const Duration(milliseconds: 150)), - const SizedBox(height: 10), - _InfoChips(emisora: emisoraActiva).pluriFadeSlideIn( - context, - delay: const Duration(milliseconds: 200), - beginY: 0.2, - ), const SizedBox(height: 6), - if (emisoraActiva.codec != null || emisoraActiva.bitrate != null) - Text( - _codecInfo(context, emisoraActiva), - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurface.withValues(alpha: 0.72), - ), - ).pluriFadeIn( - context, - delay: const Duration(milliseconds: 250), - ), + _SubtituloInfo( + emisora: emisoraActiva, + ).pluriFadeIn(context, delay: const Duration(milliseconds: 190)), + const SizedBox(height: 12), + _FilaCalidad( + estado: estado, + emisora: emisoraActiva, + ).pluriFadeIn(context, delay: const Duration(milliseconds: 230)), const SizedBox(height: 14), PluriGlassSurface( borderRadius: BorderRadius.circular(tokens.radiusLg), @@ -177,54 +165,48 @@ class _PantallaReproductorState extends State color: tokens.warmCoral, altura: 46, ), - ).pluriFadeIn(context, delay: const Duration(milliseconds: 280)), - const Spacer(), + ).pluriFadeIn(context, delay: const Duration(milliseconds: 270)), + const SizedBox(height: 22), _Controles( estado: estado, emisora: emisoraActiva, + esFavorito: esFavorito, ).pluriFadeSlideIn( context, - delay: const Duration(milliseconds: 300), + delay: const Duration(milliseconds: 310), beginY: 0.3, ), - const SizedBox(height: 14), - const _GrabacionWidget().pluriFadeIn( - context, - delay: const Duration(milliseconds: 360), - ), - const SizedBox(height: 14), - _TimerWidget( + const SizedBox(height: 20), + _BandejaHerramientas( estado: estado, - ).pluriFadeIn(context, delay: const Duration(milliseconds: 400)), - const SizedBox(height: 16), + eq: eq, + emisora: emisoraActiva, + compartir: _compartir, + ).pluriFadeIn(context, delay: const Duration(milliseconds: 350)), ], ), ), ), ); } - - String _codecInfo(BuildContext context, Emisora e) { - final parts = []; - if (e.codec != null) parts.add(e.codec!.toUpperCase()); - if (e.bitrate != null && e.bitrate! > 0) parts.add('${e.bitrate} kbps'); - return parts.isEmpty - ? AppLocalizations.of(context).qualityUnknown - : AppLocalizations.of(context).qualityOriginal(parts.join(' · ')); - } } -class _WaveHero extends StatelessWidget { +/// Square art (design proposal WU14 row: "square art" replaces the old +/// circular `_WaveHero`). Loading/error overlays and the fallback icon are +/// unchanged from the prior circular version — only the clip shape and the +/// decorative halo geometry changed from circle to rounded-square. +class _ArteReproductor extends StatelessWidget { final Emisora emisora; final Stream estadoStream; - const _WaveHero({required this.emisora, required this.estadoStream}); + const _ArteReproductor({required this.emisora, required this.estadoStream}); @override Widget build(BuildContext context) { final theme = Theme.of(context); final t = context.pluriTokens; final size = MediaQuery.of(context).size.width * 0.62; + final radio = BorderRadius.circular(t.radiusLg); return StreamBuilder( stream: estadoStream, @@ -237,6 +219,7 @@ class _WaveHero extends StatelessWidget { final hayError = snapshot.data == EstadoReproduccion.error; return SizedBox( + key: const Key('player-hero-art'), width: size + 40, height: size + 40, child: Stack( @@ -246,7 +229,7 @@ class _WaveHero extends StatelessWidget { width: size + 34, height: size + 34, decoration: BoxDecoration( - shape: BoxShape.circle, + borderRadius: BorderRadius.circular(t.radiusLg + 16), gradient: RadialGradient( colors: [ t.electricMagenta.withValues( @@ -261,17 +244,18 @@ class _WaveHero extends StatelessWidget { width: size + 12, height: size + 12, decoration: BoxDecoration( - shape: BoxShape.circle, + borderRadius: BorderRadius.circular(t.radiusLg + 4), border: Border.all(color: t.glassBorder), ), ), PluriGlassSurface( - borderRadius: BorderRadius.circular(size), + borderRadius: radio, padding: EdgeInsets.zero, child: SizedBox( width: size, height: size, - child: ClipOval( + child: ClipRRect( + borderRadius: radio, child: Stack( fit: StackFit.expand, children: [ @@ -335,44 +319,93 @@ class _WaveHero extends StatelessWidget { ); } -class _InfoChips extends StatelessWidget { +/// Single subtitle line (WU14: collapses the old `_InfoChips` `Wrap` of +/// separate chips — country/language now join as one line; codec/bitrate +/// moved into their own [_FilaCalidad] row below). +class _SubtituloInfo extends StatelessWidget { + const _SubtituloInfo({required this.emisora}); + final Emisora emisora; - const _InfoChips({required this.emisora}); @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final items = []; - if (emisora.pais != null) items.add(emisora.pais!); - if (emisora.idioma != null) items.add(emisora.idioma!); - if ((emisora.bitrate ?? 0) > 0) items.add('${emisora.bitrate} kbps'); - if (emisora.codec != null) items.add(emisora.codec!.toUpperCase()); - if (items.isEmpty) return const SizedBox.shrink(); + final partes = [ + if (emisora.pais != null && emisora.pais!.isNotEmpty) emisora.pais!, + if (emisora.idioma != null && emisora.idioma!.isNotEmpty) emisora.idioma!, + ]; + if (partes.isEmpty) return const SizedBox.shrink(); - return Wrap( - spacing: 8, - runSpacing: 6, - alignment: WrapAlignment.center, - children: - items - .map( - (label) => Chip( - label: Text(label), - visualDensity: VisualDensity.compact, - backgroundColor: theme.colorScheme.secondaryContainer - .withValues(alpha: 0.8), - labelStyle: TextStyle( - color: theme.colorScheme.onSecondaryContainer, - fontSize: 12, - ), - padding: EdgeInsets.zero, - ), - ) - .toList(), + return Text( + key: const Key('player-subtitle-line'), + partes.join(' · '), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.72), + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, ); } } +/// Quality row (WU14: "quality row + Cambiar action" per the proposal's +/// WU14 blast-radius line). **Design decision, not specified by any ADR** +/// (WU14 has none): Radio Browser stations are one fixed stream each — this +/// app has no per-station alternate-quality capability to invoke. Rather +/// than a dead "Cambiar" button or an invented picker, it reconnects the +/// current stream (the SAME `estado.reproducir(emisora)` call the error +/// state's existing "Retry" button already uses) — a real, testable, +/// zero-new-capability action, matching this branch's established +/// "don't invent a capability absent from the domain" discipline (WU5's +/// per-station artwork, WU9's dashed border). +class _FilaCalidad extends StatelessWidget { + const _FilaCalidad({required this.estado, required this.emisora}); + + final EstadoRadio estado; + final Emisora emisora; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + final tokens = context.pluriTokens; + + return PluriGlassSurface( + key: const Key('player-quality-row'), + borderRadius: BorderRadius.circular(tokens.radiusMd), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + child: Row( + children: [ + Icon(Icons.hd_rounded, size: 20, color: tokens.liveGreen), + const SizedBox(width: 10), + Expanded( + child: Text( + _codecInfo(context, emisora), + style: theme.textTheme.bodySmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + TextButton( + key: const Key('player-quality-change-action'), + onPressed: () => estado.reproducir(emisora), + child: Text(l10n.playerQualityChangeAction), + ), + ], + ), + ); + } +} + +String _codecInfo(BuildContext context, Emisora e) { + final parts = []; + if (e.codec != null) parts.add(e.codec!.toUpperCase()); + if (e.bitrate != null && e.bitrate! > 0) parts.add('${e.bitrate} kbps'); + return parts.isEmpty + ? AppLocalizations.of(context).qualityUnknown + : AppLocalizations.of(context).qualityOriginal(parts.join(' · ')); +} + class _GrabacionWidget extends StatelessWidget { // Recording state lives in EstadoGrabacion (S4-R2); EstadoRadio no longer // notifies on recording progress, so this widget watches the new notifier. @@ -656,11 +689,20 @@ const _opciones = [ _OpcionGrabacion(Duration(minutes: 30)), ]; +/// Transport row (WU14: favorite moved here from the AppBar; the old +/// standalone live/not-playing dot is gone — the AppBar's status pill +/// already covers that signal, so "favorite / stop / play-pause" is the +/// full set — matching the proposal's own "3 controles" characterisation). class _Controles extends StatelessWidget { final EstadoRadio estado; final Emisora emisora; + final bool esFavorito; - const _Controles({required this.estado, required this.emisora}); + const _Controles({ + required this.estado, + required this.emisora, + required this.esFavorito, + }); @override Widget build(BuildContext context) { @@ -709,8 +751,32 @@ class _Controles extends StatelessWidget { borderRadius: BorderRadius.circular(28), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), child: Row( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ + Semantics( + button: true, + label: + esFavorito + ? l10n.favoritesRemoveTooltip + : l10n.favoritesAddTooltip, + child: IconButton( + icon: Icon( + esFavorito + ? Icons.favorite_rounded + : Icons.favorite_outline_rounded, + ), + iconSize: 28, + color: + esFavorito + ? theme.colorScheme.error + : theme.colorScheme.onSurface.withValues(alpha: 0.78), + tooltip: + esFavorito + ? l10n.favoritesRemoveTooltip + : l10n.favoritesAddTooltip, + onPressed: () => estado.toggleFavorito(emisora), + ), + ), Semantics( button: true, label: l10n.stopPlaybackTooltip, @@ -726,7 +792,6 @@ class _Controles extends StatelessWidget { onPressed: cargando ? null : estado.detenerReproduccion, ), ), - const SizedBox(width: 16), AnimatedContainer( duration: const Duration(milliseconds: 200), width: 72, @@ -788,18 +853,6 @@ class _Controles extends StatelessWidget { ), ), ), - const SizedBox(width: 16), - Semantics( - label: reproduciendo ? l10n.liveNow : l10n.notPlaying, - child: Icon( - Icons.fiber_manual_record_rounded, - size: 32, - color: - reproduciendo - ? theme.colorScheme.error - : theme.colorScheme.surfaceContainerHighest, - ), - ), ], ), ); @@ -808,99 +861,259 @@ class _Controles extends StatelessWidget { } } -class _TimerWidget extends StatelessWidget { - final EstadoRadio estado; - const _TimerWidget({required this.estado}); +/// One tile of the 4-tile tool tray (WU14: EQ propio / Grabar / sleep timer +/// / Compartir), each opening its own bottom sheet (Compartir invokes +/// directly instead — there is no sheet content for it). +class _TileHerramienta extends StatelessWidget { + const _TileHerramienta({ + super.key, + required this.icon, + required this.label, + required this.onTap, + this.iconColor, + }); + + final IconData icon; + final String label; + final VoidCallback onTap; + final Color? iconColor; @override Widget build(BuildContext context) { final theme = Theme.of(context); + final tokens = context.pluriTokens; - if (!estado.timer.activo) { - return TextButton.icon( - icon: const Icon(Icons.bedtime_outlined, size: 18), - label: Text(AppLocalizations.of(context).sleepTimer), - onPressed: () => _mostrarTimerDialog(context), - ); - } - - return StreamBuilder( - stream: estado.timer.tiempoRestanteStream, - builder: (context, snap) { - final t = snap.data ?? Duration.zero; - final m = t.inMinutes.remainder(60).toString().padLeft(2, '0'); - final s = t.inSeconds.remainder(60).toString().padLeft(2, '0'); - final label = - t.inHours > 0 - ? AppLocalizations.of( - context, - ).durationHoursMinutesSeconds(t.inHours, m, s) - : AppLocalizations.of(context).durationMinutesSeconds(m, s); - - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.bedtime_rounded, - size: 16, - color: theme.colorScheme.primary, - ), - const SizedBox(width: 6), - Text(label, style: theme.textTheme.bodyMedium), - const SizedBox(width: 8), - TextButton( - onPressed: () => estado.cancelarTimer(), - style: TextButton.styleFrom( - visualDensity: VisualDensity.compact, - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - ), - child: Text(AppLocalizations.of(context).cancelAction), - ), - ], - ); - }, - ); - } - - void _mostrarTimerDialog(BuildContext context) { - showModalBottomSheet( - context: context, - builder: - (ctx) => SafeArea( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - AppLocalizations.of(ctx).sleepTimer, - style: Theme.of(ctx).textTheme.titleLarge, - ), - const SizedBox(height: 16), - Wrap( - spacing: 8, - children: - opcionesTimer - .map( - (min) => ActionChip( - label: Text( - AppLocalizations.of( - ctx, - ).durationMinutesOnly(min), - ), - onPressed: () { - estado.iniciarTimer(min); - Navigator.pop(ctx); - }, - ), - ) - .toList(), - ), - ], - ), - ), + return Material( + type: MaterialType.transparency, + child: InkWell( + borderRadius: BorderRadius.circular(tokens.radiusSm), + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 6), + decoration: BoxDecoration( + color: tokens.listSurface.withValues(alpha: 0.55), + borderRadius: BorderRadius.circular(tokens.radiusSm), ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 23, color: iconColor ?? tokens.electricMagenta), + const SizedBox(height: 5), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: theme.textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + ], + ), + ), + ), ); } } + +class _BandejaHerramientas extends StatelessWidget { + const _BandejaHerramientas({ + required this.estado, + required this.eq, + required this.emisora, + required this.compartir, + }); + + final EstadoRadio estado; + final EstadoEcualizador eq; + final Emisora emisora; + final Future Function(String) compartir; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _TileHerramienta( + key: const Key('player-tool-eq'), + icon: Icons.equalizer_rounded, + label: l10n.playerToolEqLabel, + onTap: () => _mostrarHojaEq(context, eq, emisora), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Consumer( + builder: (context, grabacion, _) { + final activa = grabacion.estado.activa; + return _TileHerramienta( + key: const Key('player-tool-record'), + icon: + activa + ? Icons.fiber_manual_record_rounded + : Icons.mic_rounded, + iconColor: activa ? Theme.of(context).colorScheme.error : null, + label: activa ? l10n.recordingActiveTitle : l10n.recordAction, + onTap: () => _mostrarHojaGrabacion(context), + ); + }, + ), + ), + const SizedBox(width: 10), + Expanded( + child: + !estado.timer.activo + ? _TileHerramienta( + key: const Key('player-tool-sleep'), + icon: Icons.bedtime_outlined, + label: l10n.sleepTimer, + onTap: () => _mostrarHojaTimer(context, estado), + ) + : StreamBuilder( + stream: estado.timer.tiempoRestanteStream, + builder: (context, snap) { + final t = snap.data ?? estado.timer.tiempoRestante; + return _TileHerramienta( + key: const Key('player-tool-sleep'), + icon: Icons.bedtime_rounded, + label: _formatearTiempoRestante(context, t), + onTap: () => _mostrarHojaTimer(context, estado), + ); + }, + ), + ), + const SizedBox(width: 10), + Expanded( + child: _TileHerramienta( + key: const Key('player-tool-share'), + icon: Icons.share_rounded, + label: l10n.recordingActionShare, + onTap: () => compartir('${emisora.nombre}\n${emisora.url}'), + ), + ), + ], + ); + } +} + +String _formatearTiempoRestante(BuildContext context, Duration t) { + final l10n = AppLocalizations.of(context); + final m = t.inMinutes.remainder(60).toString().padLeft(2, '0'); + final s = t.inSeconds.remainder(60).toString().padLeft(2, '0'); + return t.inHours > 0 + ? l10n.durationHoursMinutesSeconds(t.inHours, m, s) + : l10n.durationMinutesSeconds(m, s); +} + +/// Opens the per-station EQ sheet. Reuses [EcualizadorWidget] by its exact +/// runtime type (design ADR-5, spec `eq-custom-presets` "Per-Station EQ +/// Entry Point From the Player") — bound via the SAME per-station +/// persistence path Settings' Ecualizador screen (WU13) uses, just against +/// this station's uuid instead of `presetPrincipal`. Deliberately just the +/// 5 sliders (no preset-chip row) — the spec scenario names "5 sliders", +/// not a full preset picker for a single-station override. +Future _mostrarHojaEq( + BuildContext context, + EstadoEcualizador eq, + Emisora emisora, +) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + builder: + (ctx) => Padding( + padding: EdgeInsets.fromLTRB( + 16, + 0, + 16, + MediaQuery.viewInsetsOf(ctx).bottom + 24, + ), + child: EcualizadorWidget( + key: const Key('player-eq-sheet-editor'), + preset: eq.presetParaEmisora(emisora.uuid), + habilitado: eq.activo, + onCambio: (p) => eq.guardarPresetPorEmisora(emisora.uuid, p), + ), + ), + ); +} + +/// Opens the recording sheet (WU14: relocates the old always-expanded +/// `_GrabacionWidget` card behind the "Grabar" tool-tray tile — its OWN +/// content/logic is unchanged, only its call site moves). +Future _mostrarHojaGrabacion(BuildContext context) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + builder: + (ctx) => Padding( + padding: EdgeInsets.fromLTRB( + 16, + 0, + 16, + MediaQuery.viewInsetsOf(ctx).bottom + 24, + ), + child: const _GrabacionWidget(), + ), + ); +} + +/// Opens the sleep-timer sheet (WU14: relocates the old standalone +/// `_TimerWidget` button/inline-countdown behind the "Sleep timer" +/// tool-tray tile). Adds a "Cancel timer" option at the top when a timer is +/// already active — that capability existed inline before (the old +/// countdown row's own Cancel button) and is preserved, not dropped. +Future _mostrarHojaTimer(BuildContext context, EstadoRadio estado) { + final l10n = AppLocalizations.of(context); + return showModalBottomSheet( + context: context, + builder: + (ctx) => SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + AppLocalizations.of(ctx).sleepTimer, + style: Theme.of(ctx).textTheme.titleLarge, + ), + const SizedBox(height: 16), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + if (estado.timer.activo) + ActionChip( + key: const Key('player-sleep-cancel-action'), + avatar: const Icon(Icons.close_rounded, size: 18), + label: Text(l10n.cancelAction), + onPressed: () { + estado.cancelarTimer(); + Navigator.pop(ctx); + }, + ), + for (final min in opcionesTimer) + ActionChip( + label: Text( + AppLocalizations.of(ctx).durationMinutesOnly(min), + ), + onPressed: () { + estado.iniciarTimer(min); + Navigator.pop(ctx); + }, + ), + ], + ), + ], + ), + ), + ), + ); +} diff --git a/openspec/changes/rediseno-funcional/tasks.md b/openspec/changes/rediseno-funcional/tasks.md index b4e2e73..79e511f 100644 --- a/openspec/changes/rediseno-funcional/tasks.md +++ b/openspec/changes/rediseno-funcional/tasks.md @@ -52,7 +52,7 @@ | 10 | `feat(alarmas): rewrite alarm editor with inline time widget` | 8 | 500-650 | High | **Yes — indivisible new widget**‡ | | 11 | `feat(alarma-sonando): restyle ringing screen, drop live countdown label` | 1 | 200-300 | Low (safety-critical review attention: High) | No | | 13 | `feat(eq): restyle equalizer screen and add custom presets` | 3a | ~~400-550~~ → **REALIZED: 1,871** (1,793+ / 78-, 25 files) | Medium-High | **Yes — retroactive, see WU13 section** | -| 14 | `feat(reproductor): restructure full player with tool-tray and EQ sheet` | 13 | 450-600 | Medium-High | Monitor | +| 14 | `feat(reproductor): restructure full player with tool-tray and EQ sheet` | 13 | ~~450-600~~ → **REALIZED: 1,335** (1,114+ / 221-, 19 files) | Medium-High | **Yes — retroactive, see WU14 section** | | 15 | `feat(grabaciones): add recordings library screen` | 3b | ~~300-400~~ → **REALIZED: 1,767** (1,767+ / 0-, 22 files) | Medium | Monitor§ | | 15b | `fix(grabaciones): wire the recordings library into Settings navigation` | 15 | 60-100 | Low | No | | 16 | `feat(connectivity): restyle offline and reconnect banners` | 1 | 150-250 | Low | No | @@ -807,24 +807,67 @@ Per-Station EQ Entry Relocates, Resolution Logic Does Not **Modified tests**: `pantalla_reproductor_test.dart` (or equivalent widget test). Three EQ test files must pass **unmodified**. -- [ ] 14.1 RED — the per-station EQ bottom sheet renders **the same `EcualizadorWidget` type** WU13 restyled +- [x] 14.1 RED — the per-station EQ bottom sheet renders **the same `EcualizadorWidget` type** WU13 restyled (assert by `runtimeType`, so a duplicate implementation fails the test, not just a visual review). -- [ ] 14.2 RED — the 4 tool-tray tiles ("EQ propio", "Grabar", sleep-timer value, "Compartir") each open their own - bottom sheet. -- [ ] 14.3 RED — opening "EQ propio" for a playing station shows 5 sliders bound to that station's resolved preset, +- [x] 14.2 RED — the 4 tool-tray tiles ("EQ propio", "Grabar", sleep-timer value, "Compartir") each open their own + bottom sheet (Compartir invokes directly — there is no sheet content for a share action). +- [x] 14.3 RED — opening "EQ propio" for a playing station shows 5 sliders bound to that station's resolved preset, and a change round-trips through the existing per-station persistence path (`presetsPorEmisora` / `presetsMatriz`). -- [ ] 14.4 GREEN — restructure `pantalla_reproductor.dart`: square art, favorite moved into the transport row, - single subtitle line (collapse the current `_InfoChips` `Wrap`), 4-tile tool-tray grid replacing the - always-expanded recording panel + separate sleep-timer button + EQ toggle. -- [ ] 14.5 GREEN — wire "EQ propio" to a bottom sheet hosting `EcualizadorWidget` bound via - `EstadoEcualizador.presetParaEmisora(uuid)` / `guardarPresetPorEmisora(uuid, ...)`. -- [ ] 14.6 GREEN — add the quality row + "Cambiar" action and the "Compartir" tool-tray tile. -- [ ] 14.7 REFACTOR — confirm no second EQ editor file was created; confirm the `multi-device-eq` regression - scenarios (device-event resolve-and-apply, first-seen bootstrap, cold start, connect/disconnect/reconnect, - toggle-off) still pass unmodified. -- [ ] 14.8 Verify — the 3 EQ test files remain green and unmodified; `EcualizadorWidget` type-identity assertion - passes. +- [x] 14.4 GREEN — restructured `pantalla_reproductor.dart` onto `PluriPushScaffold` (design ADR-2 — this screen is + the documented single consumer of `titleOverride`, a centered live/not-playing `PluriStatusPill`, and of the + non-default `leadingIcon: keyboard_arrow_down_rounded`): square art (`ClipRRect`, was `ClipOval`), favorite + moved into the transport row (the old redundant live-indicator dot removed — the AppBar pill already covers + that signal, so "favorite / stop / play-pause" matches the proposal's own "3 controles" note), single + subtitle line (collapses the old `_InfoChips` `Wrap`; codec/bitrate moved to the new quality row), 4-tile + tool-tray row replacing the always-expanded recording panel + separate sleep-timer button + EQ toggle. Body + wrapped in `SingleChildScrollView` (was a fixed `Column` + `Spacer()`, which overflowed even a generously + tall test viewport — see 2 bugfixes below). +- [x] 14.5 GREEN — wired "EQ propio" to a bottom sheet hosting `EcualizadorWidget` bound via + `EstadoEcualizador.presetParaEmisora(uuid)` / `guardarPresetPorEmisora(uuid, ...)`. Deliberately just the 5 + sliders, no preset-chip row — the spec scenario's own wording is "5 sliders", and ADR-5's wiring table only + names `EcualizadorWidget` for this consumer. +- [x] 14.6 GREEN — added the quality row + "Cambiar" action and the "Compartir" tool-tray tile. **Design decision, + not specified by any ADR (WU14 has none)**: this app has no per-station alternate-quality capability to + invoke (a Radio Browser station is one fixed stream) — "Cambiar" reconnects the current stream (the SAME + `estado.reproducir(emisora)` call the existing error-state "Retry" button already uses) instead of a dead + button or an invented picker, matching this branch's "don't invent a capability absent from the domain" + discipline (WU5 per-station artwork, WU9 dashed border). "Compartir" mirrors WU15's injectable `compartir` + constructor-parameter pattern (defaults to the real `share_plus` call), sharing the station name + stream url. +- [x] 14.7 REFACTOR — confirmed no second EQ editor file was created (`grep`-equivalent: only one `EcualizadorWidget` + class exists, in `lib/widgets/ecualizador_widget.dart`, imported and reused here); confirmed the + `multi-device-eq` regression scenarios in `estado_ecualizador_test.dart`'s "4-level resolution (Phase 5)" + group still pass unmodified (this WU never touches `EstadoEcualizador`'s resolution logic, only calls its + EXISTING `presetParaEmisora`/`guardarPresetPorEmisora` methods). Removed dead code found during the + restructure: `_pulseController` (an `AnimationController` created and disposed but never actually driven by + anything) and the `SingleTickerProviderStateMixin` it required. +- [x] 14.8 Verify — the 3 EQ test files remain green and **unmodified**; `EcualizadorWidget` type-identity assertion + passes (both via `find.byType` and a `runtimeType`-predicate structural regression guard). Full suite: + 730/730 green (2 skipped, unchanged), up from 713. + +**Two pre-existing bugs found and fixed, surfaced by writing this screen's first-ever test coverage** (both +directly blocked test coverage from working at all, so neither could be deferred): +1. `initState` called `estado.reproducir(...)` directly, which calls `notifyListeners()` **synchronously** before + its first `await` when no recording needs stopping — threw "setState() or markNeedsBuild() called during + build" the instant this screen mounted against a fresh Provider tree. Fixed via `WidgetsBinding.instance. + addPostFrameCallback`. +2. The body `Column` had no scrollable ancestor and overflowed the default 800x600 test viewport (and would + overflow on a genuinely short real device too, given the content: hero, name, subtitle, quality row, visualizer, + transport, tool tray). Fixed by wrapping the body in `SingleChildScrollView` (see 14.4) — a real UX improvement, + not just a test workaround. + +**`size:exception` recorded.** Realized: **1,335 changed lines** (1,114+/221-) across 19 files against the +450-600 forecast — same "a strict-TDD commit carries its test files" pattern as every prior WU (Engram +`reference/estimating-strict-tdd-diffs`, id 2514), though smaller this time since only 2 new ARB keys were needed +(`playerToolEqLabel`, `playerQualityChangeAction` — everything else reused existing keys: `recordAction`, +`recordingActiveTitle`, `sleepTimer`, `recordingActionShare`, `liveNow`, `notPlaying`, `qualityOriginal`, +`qualityUnknown`). Breakdown: `lib/pantallas/pantalla_reproductor.dart` alone is 658 lines (a near-total +restructure of a 907-line file, not a small patch); the new `pantalla_reproductor_test.dart` (writing coverage for +a file that had ZERO before this commit, per this WU's own explicit mandate) is 519 lines; `test/helpers/fakes.dart` +gained 64 (new `FakeServicioGrabacionRadioActivable` plus a `togglePlay()` override the play/pause characterization +test needed); the rest is the 2-key ARB/l10n-gen cascade. Not splittable: the restructure, the tool tray, and the +EQ-sheet wiring are one cohesive change to one screen — a split would leave either an unstyled screen with a tool +tray that has nothing to open, or a tool tray with no restructured screen to live in. ## WU15 — Grabaciones library (new list) diff --git a/test/helpers/fakes.dart b/test/helpers/fakes.dart index 5a1bccb..cb1c404 100644 --- a/test/helpers/fakes.dart +++ b/test/helpers/fakes.dart @@ -77,6 +77,21 @@ class FakeServicioAudio extends ServicioAudio { emitirEstado(EstadoReproduccion.pausado); } + // WU14: the real ServicioAudio.togglePlay() reads `_handler.playbackState` + // (a real just_audio-backed handler that requires registrarHandler(), same + // gap already documented for androidAudioSessionIdStream above) — unsafe + // against a bare FakeServicioAudio. Overridden here using only this Fake's + // own state machinery so `pantalla_reproductor.dart`'s play/pause control + // (previously untested) can be exercised safely. + @override + Future togglePlay() async { + if (_estadoActual == EstadoReproduccion.reproduciendo) { + await pausar(); + } else { + emitirEstado(EstadoReproduccion.reproduciendo); + } + } + @override Future setVolumen(double vol) async { volumenesAplicados.add(vol); @@ -639,6 +654,55 @@ class FakeServicioGrabacionRadio extends ServicioGrabacionRadio { Future dispose() => _controller.close(); } +/// WU14: a recording fake that actually responds to `iniciar`/`detener` +/// in-memory, never touching real files or platform channels (`iniciar` on +/// the real `ServicioGrabacionRadio` opens an HTTP stream to the station's +/// URL and writes to disk — unsafe inside a widget test). Records every +/// call for assertions. +class FakeServicioGrabacionRadioActivable extends ServicioGrabacionRadio { + final _controller = StreamController.broadcast(); + EstadoGrabacionRadio _estadoActual = const EstadoGrabacionRadio.inactiva(); + final List duracionesIniciadas = []; + Emisora? ultimaEmisoraIniciada; + int detenerCalls = 0; + + @override + EstadoGrabacionRadio get estado => _estadoActual; + + @override + Stream get estadoStream => _controller.stream; + + @override + Future inicializar() async {} + + @override + Future iniciar( + Emisora emisora, { + Duration? duracion, + String? directorio, + }) async { + ultimaEmisoraIniciada = emisora; + duracionesIniciadas.add(duracion); + _estadoActual = EstadoGrabacionRadio( + tipo: EstadoGrabacionRadioTipo.grabando, + emisora: emisora, + inicio: DateTime.now(), + duracionObjetivo: duracion, + ); + _controller.add(_estadoActual); + } + + @override + Future detener() async { + detenerCalls++; + _estadoActual = const EstadoGrabacionRadio.inactiva(); + _controller.add(_estadoActual); + } + + @override + Future dispose() => _controller.close(); +} + Emisora emisoraDemo({ required String uuid, required String nombre, diff --git a/test/pantallas/pantalla_reproductor_test.dart b/test/pantallas/pantalla_reproductor_test.dart new file mode 100644 index 0000000..46a0fe4 --- /dev/null +++ b/test/pantallas/pantalla_reproductor_test.dart @@ -0,0 +1,522 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/estado/estado_ecualizador.dart'; +import 'package:pluriwave/estado/estado_grabacion.dart'; +import 'package:pluriwave/estado/estado_radio.dart'; +import 'package:pluriwave/l10n/gen/app_localizations.dart'; +import 'package:pluriwave/modelos/emisora.dart'; +import 'package:pluriwave/modelos/preset_ecualizador.dart'; +import 'package:pluriwave/pantallas/pantalla_reproductor.dart'; +import 'package:pluriwave/widgets/ecualizador_widget.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../helpers/fakes.dart'; +import '../helpers/fakes_alarmas.dart'; + +/// `pantalla_reproductor.dart` (907 lines) had ZERO test coverage before +/// this commit — a gap first surfaced during WU5 (no test in this codebase +/// had ever exercised `androidAudioSessionIdStream` against a bare +/// `FakeServicioAudio` until the Escuchar hero wired `VisualizadorAudio` to +/// it). Strict TDD requires coverage BEFORE restructuring this screen, not +/// restructuring first and backfilling after — see the two groups below: +/// +/// - `Characterization (pre-WU14 baseline)`: written and run GREEN against +/// the screen's CURRENT, unmodified structure (its own commit, before +/// this file's restructure). These pin the state-mutation CONTRACTS that +/// must survive WU14 unchanged, even though the WIDGETS that trigger them +/// move (favorite leaves the AppBar, EQ toggle is replaced by the +/// per-station EQ sheet, the always-expanded recording panel and the +/// standalone sleep-timer button both become tool-tray tiles). +/// - `WU14 — tool tray, square art, EQ sheet reuse`: the NEW target +/// structure's RED tests, satisfied by the restructure itself. +/// +/// Two pre-existing bugs surfaced by writing this coverage (both fixed as +/// part of this WU, since neither can be worked around from the test side): +/// 1. `initState` called `estado.reproducir(...)` directly, which notifies +/// `EstadoRadio` listeners SYNCHRONOUSLY before its first `await` (no +/// active recording to stop) — threw "setState() or markNeedsBuild() +/// called during build" the instant this screen mounted against a fresh +/// Provider tree. Fixed via `addPostFrameCallback`. +/// 2. The body `Column` has no scrollable ancestor and overflows the +/// default 800x600 test viewport (and would overflow on a short real +/// device too) — worked around here via the same `physicalSize` +/// override `pantalla_alarma_sonando_test.dart` already established, +/// which does not require a production change to test against. +/// +/// A third, environment-specific quirk (not a production bug — the same +/// `showModalBottomSheet` renders correctly in production and its dialog +/// TITLE is always found by these tests, confirming the sheet opens): +/// `tester.tap()` by widget position against an `ActionChip` or +/// `FilledButton` inside this screen's non-scroll-controlled bottom sheets +/// intermittently resolves an offset outside the test viewport regardless +/// of viewport size or `disableAnimations`. Every such action inside a +/// bottom sheet is invoked directly via its own `onPressed` callback +/// instead of `tester.tap()` — +/// this only bypasses hit-test positioning, not the actual production +/// callback wiring under test. +/// +/// `VisualizadorAudio` starts a repeating `AnimationController` once +/// playback reaches "reproduciendo" (WU5's documented hazard) — `initState` +/// here calls `estado.reproducir(...)` unconditionally, so EVERY test in +/// this file reaches "reproduciendo" almost immediately. `disableAnimations: +/// true` (set below) keeps `flutter_animate`'s entrance-animation delays +/// from leaving a pending `Timer` at test end; every pump is still bounded +/// (`pump()` / `pump(Duration(...))`), never `pumpAndSettle()`. +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + final emisora = emisoraDemo(uuid: 'demo-uuid', nombre: 'Radio Demo'); + + EstadoRadio crearEstado({ + FakeServicioGrabacionRadioActivable? grabacion, + List favoritosIniciales = const [], + Map? porEmisora, + }) { + final favoritos = FakeServicioFavoritos(); + for (final e in favoritosIniciales) { + unawaited(favoritos.agregar(e)); + } + return EstadoRadio( + audio: FakeServicioAudio(), + favoritos: favoritos, + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(porEmisora: porEmisora), + servicioGrabacion: grabacion ?? FakeServicioGrabacionRadioInactiva(), + iniciarAutomaticamente: false, + ); + } + + Widget buildScreen( + EstadoRadio estado, { + Emisora? estacion, + Future Function(String)? compartir, + }) { + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: estado), + ListenableProvider.value(value: estado.ecualizador), + ListenableProvider.value(value: estado.grabacion), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: + (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(disableAnimations: true), + child: child!, + ), + home: PantallaReproductor( + emisora: estacion ?? emisora, + compartir: compartir, + ), + ), + ); + } + + /// The default 800x600 test viewport is shorter than this screen's + /// non-scrolling content (never caught before, zero prior coverage) — + /// same fix `pantalla_alarma_sonando_test.dart` already established for + /// another full-bleed hero screen. + Future montarPantalla( + WidgetTester tester, + EstadoRadio estado, { + Emisora? estacion, + Future Function(String)? compartir, + }) async { + tester.view.physicalSize = const Size(1440, 3200); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + buildScreen(estado, estacion: estacion, compartir: compartir), + ); + await tester.pump(); + } + + /// Invokes an `ActionChip`'s own `onPressed` directly, bypassing + /// hit-testing — see the file-level doc comment for why. + Future presionarActionChip(WidgetTester tester, String label) async { + final chip = tester.widget( + find.widgetWithText(ActionChip, label), + ); + chip.onPressed?.call(); + await tester.pump(const Duration(milliseconds: 300)); + } + + /// Same idea for a `FilledButton` inside a bottom sheet. + Future presionarFilledButton(WidgetTester tester, String label) async { + final boton = tester.widget( + find.widgetWithText(FilledButton, label), + ); + boton.onPressed?.call(); + await tester.pump(const Duration(milliseconds: 300)); + } + + group('Characterization (pre-WU14 baseline)', () { + testWidgets('opening the screen starts playback for the given station', ( + tester, + ) async { + final estado = crearEstado(); + addTearDown(estado.dispose); + + await montarPantalla(tester, estado); + + expect(estado.emisoraActual?.uuid, equals(emisora.uuid)); + }); + + testWidgets( + 'opening the screen for the ALREADY-active station does not restart playback', + (tester) async { + final estado = crearEstado(); + addTearDown(estado.dispose); + await estado.reproducir(emisora); + final llamadasPrevias = + (estado.audio as FakeServicioAudio).emisorasReproducidas.length; + + await montarPantalla(tester, estado); + + expect( + (estado.audio as FakeServicioAudio).emisorasReproducidas.length, + equals(llamadasPrevias), + ); + }, + ); + + testWidgets('tapping the primary button while playing pauses playback', ( + tester, + ) async { + final estado = crearEstado(); + addTearDown(estado.dispose); + + await montarPantalla(tester, estado); + expect(estado.audio.estaSonando, isTrue); + + await tester.tap(find.byIcon(Icons.pause_rounded)); + await tester.pump(); + + expect(estado.audio.estaSonando, isFalse); + }); + + testWidgets('tapping stop calls detenerReproduccion', (tester) async { + final estado = crearEstado(); + addTearDown(estado.dispose); + + await montarPantalla(tester, estado); + expect(estado.audio.estaSonando, isTrue); + + await tester.tap(find.byIcon(Icons.stop_rounded)); + await tester.pump(); + + // detenerReproduccion() stops playback but does NOT clear + // emisoraActual (EstadoRadio.emisoraActual falls back to + // _emisoraSeleccionada, which stays set so the screen keeps showing + // the last selected station in its "stopped" state) — estaSonando is + // the correct signal for "stop actually happened". + expect(estado.audio.estaSonando, isFalse); + expect(estado.emisoraActual?.uuid, equals(emisora.uuid)); + }); + + testWidgets('tapping favorite toggles the station favorite status', ( + tester, + ) async { + final estado = crearEstado(); + addTearDown(estado.dispose); + + await montarPantalla(tester, estado); + expect(estado.listaFavoritos.any((e) => e.uuid == emisora.uuid), isFalse); + + await tester.tap(find.byIcon(Icons.favorite_outline_rounded)); + await tester.pump(); + + expect(estado.listaFavoritos.any((e) => e.uuid == emisora.uuid), isTrue); + }); + + testWidgets( + 'starting an indefinite recording calls EstadoGrabacion.iniciar with no duration', + (tester) async { + final grabacionFake = FakeServicioGrabacionRadioActivable(); + final estado = crearEstado(grabacion: grabacionFake); + addTearDown(estado.dispose); + + await montarPantalla(tester, estado); + + // Tool-tray "Grabar" tile opens the (relocated, unchanged) + // `_GrabacionWidget` status card first — its OWN "Record" button + // (a FilledButton, scoped to disambiguate from the tile's identical + // label behind it) then opens the duration-picker sheet. + await tester.tap(find.text('Record')); + await tester.pump(const Duration(milliseconds: 300)); + await presionarFilledButton(tester, 'Record'); + await presionarActionChip(tester, 'Indefinite'); + + expect(grabacionFake.ultimaEmisoraIniciada?.uuid, equals(emisora.uuid)); + expect(grabacionFake.duracionesIniciadas, equals([null])); + }, + ); + + testWidgets( + 'starting a custom-duration recording validates and calls iniciar with that duration', + (tester) async { + final grabacionFake = FakeServicioGrabacionRadioActivable(); + final estado = crearEstado(grabacion: grabacionFake); + addTearDown(estado.dispose); + + await montarPantalla(tester, estado); + + await tester.tap(find.text('Record')); + await tester.pump(const Duration(milliseconds: 300)); + await presionarFilledButton(tester, 'Record'); + await presionarActionChip(tester, 'Custom'); + + await tester.enterText( + find.widgetWithText(TextFormField, 'Minutes'), + '5', + ); + // The trigger button behind the dialog is ALSO labelled "Record" — + // scope to the dialog's own confirm button specifically. + await tester.tap( + find.descendant( + of: find.byType(AlertDialog), + matching: find.text('Record'), + ), + ); + await tester.pump(const Duration(milliseconds: 300)); + + expect( + grabacionFake.duracionesIniciadas, + equals([const Duration(minutes: 5)]), + ); + }, + ); + + testWidgets('starting the sleep timer via a duration chip', (tester) async { + final estado = crearEstado(); + addTearDown(estado.dispose); + + await montarPantalla(tester, estado); + expect(estado.timer.activo, isFalse); + + await tester.tap(find.text('Sleep timer')); + await tester.pump(const Duration(milliseconds: 300)); + await presionarActionChip(tester, '15 min'); + + expect(estado.timer.activo, isTrue); + + // ServicioTimer starts a real Timer.periodic(1s) — flutter_test's + // pending-timer check runs before addTearDown(estado.dispose) below, + // so it must be cancelled here, inside the test body, not left to + // teardown. + estado.cancelarTimer(); + }); + }); + + group( + 'WU14 — square art, single subtitle, quality row, tool tray, EQ sheet reuse', + () { + testWidgets( + 'the hero art is square (ClipRRect), not circular (no ClipOval)', + (tester) async { + final estado = crearEstado(); + addTearDown(estado.dispose); + + await montarPantalla(tester, estado); + + final arte = find.byKey(const Key('player-hero-art')); + expect(arte, findsOneWidget); + expect( + find.descendant(of: arte, matching: find.byType(ClipRRect)), + findsWidgets, + ); + expect( + find.descendant(of: arte, matching: find.byType(ClipOval)), + findsNothing, + ); + }, + ); + + testWidgets('favorite lives in the transport row, not the AppBar', ( + tester, + ) async { + final estado = crearEstado(); + addTearDown(estado.dispose); + + await montarPantalla(tester, estado); + + final favorito = find.byIcon(Icons.favorite_outline_rounded); + expect(favorito, findsOneWidget); + expect( + find.ancestor(of: favorito, matching: find.byType(AppBar)), + findsNothing, + ); + }); + + testWidgets( + 'a single subtitle line replaces the old separate info chips', + (tester) async { + const estacion = Emisora( + uuid: 'demo-uuid', + nombre: 'Radio Demo', + url: 'https://stream.demo/radio', + pais: 'Argentina', + idioma: 'Español', + codec: 'MP3', + bitrate: 128, + ); + final estado = crearEstado(); + addTearDown(estado.dispose); + + await montarPantalla(tester, estado, estacion: estacion); + + expect(find.byType(Chip), findsNothing); + expect(find.byKey(const Key('player-subtitle-line')), findsOneWidget); + expect(find.text('Argentina · Español'), findsOneWidget); + }, + ); + + testWidgets( + 'the quality row shows codec/bitrate; Change reconnects the current stream', + (tester) async { + const estacion = Emisora( + uuid: 'demo-uuid', + nombre: 'Radio Demo', + url: 'https://stream.demo/radio', + codec: 'MP3', + bitrate: 128, + ); + final estado = crearEstado(); + addTearDown(estado.dispose); + + await montarPantalla(tester, estado, estacion: estacion); + final llamadasPrevias = + (estado.audio as FakeServicioAudio).emisorasReproducidas.length; + + expect(find.byKey(const Key('player-quality-row')), findsOneWidget); + expect(find.textContaining('MP3'), findsOneWidget); + expect(find.text('Change'), findsOneWidget); + + await tester.tap( + find.byKey(const Key('player-quality-change-action')), + ); + await tester.pump(); + + expect( + (estado.audio as FakeServicioAudio).emisorasReproducidas.length, + greaterThan(llamadasPrevias), + ); + }, + ); + + testWidgets( + 'exactly 4 tool-tray tiles render: EQ propio, Grabar, sleep timer, Compartir', + (tester) async { + final estado = crearEstado(); + addTearDown(estado.dispose); + + await montarPantalla(tester, estado); + + expect(find.byKey(const Key('player-tool-eq')), findsOneWidget); + expect(find.byKey(const Key('player-tool-record')), findsOneWidget); + expect(find.byKey(const Key('player-tool-sleep')), findsOneWidget); + expect(find.byKey(const Key('player-tool-share')), findsOneWidget); + }, + ); + + testWidgets( + 'tapping Compartir invokes the injected share callback with the station name and url', + (tester) async { + final estado = crearEstado(); + addTearDown(estado.dispose); + String? compartido; + + await montarPantalla( + tester, + estado, + compartir: (texto) async => compartido = texto, + ); + + await tester.tap(find.byKey(const Key('player-tool-share'))); + await tester.pump(); + + expect(compartido, contains(emisora.nombre)); + expect(compartido, contains(emisora.url)); + }, + ); + + testWidgets( + 'tapping EQ propio opens a sheet reusing EcualizadorWidget by exact runtime type', + (tester) async { + final estado = crearEstado( + porEmisora: {'demo-uuid': PresetEcualizador.rock}, + ); + addTearDown(estado.dispose); + await estado.ecualizador.cargarPersistido(); + + await montarPantalla(tester, estado); + + await tester.tap(find.byKey(const Key('player-tool-eq'))); + await tester.pump(const Duration(milliseconds: 400)); + + final editores = tester.widgetList(find.byType(EcualizadorWidget)); + expect(editores, hasLength(1)); + expect(editores.single.runtimeType, equals(EcualizadorWidget)); + expect(find.byType(Slider), findsNWidgets(5)); + }, + ); + + testWidgets( + 'the per-station EQ sheet is bound to the resolved preset and round-trips a change', + (tester) async { + final estado = crearEstado( + porEmisora: {'demo-uuid': PresetEcualizador.rock}, + ); + addTearDown(estado.dispose); + await estado.ecualizador.cargarPersistido(); + + await montarPantalla(tester, estado); + await tester.tap(find.byKey(const Key('player-tool-eq'))); + await tester.pump(const Duration(milliseconds: 400)); + + final primerSlider = tester.widget(find.byType(Slider).first); + expect( + primerSlider.value, + equals(PresetEcualizador.rock.bandas.first), + ); + + primerSlider.onChanged?.call(4.0); + await tester.pump(); + + expect( + estado.ecualizador.presetsPorEmisora['demo-uuid']?.bandas.first, + equals(4.0), + ); + }, + ); + + testWidgets( + 'no second EQ editor file exists — the sheet and Settings share the one EcualizadorWidget class', + (tester) async { + final estado = crearEstado(); + addTearDown(estado.dispose); + + await montarPantalla(tester, estado); + await tester.tap(find.byKey(const Key('player-tool-eq'))); + await tester.pump(const Duration(milliseconds: 400)); + + // A structural regression guard: if a future change introduced a + // parallel editor widget, this assertion (exact type, not "a + // widget that looks like an equalizer") would catch it. + expect( + find.byWidgetPredicate((w) => w.runtimeType == EcualizadorWidget), + findsOneWidget, + ); + }, + ); + }, + ); +}