feat(reproductor): restructure full player with tool-tray and EQ sheet
Restructure pantalla_reproductor.dart onto PluriPushScaffold (design ADR-2 - this screen is the documented single consumer of titleOverride, a centered live/not-playing status pill, and the non-default keyboard_arrow_down leadingIcon). Square art replaces the old circular hero, favorite moves from the AppBar into the transport row (the redundant live-indicator dot is dropped - the AppBar pill already covers that signal), the old separate info chips collapse into a single subtitle line, and a new quality row surfaces codec/bitrate with a "Cambiar" action that reconnects the current stream (this app has no per-station alternate-quality capability to invoke, so this reuses the same reproducir() call the existing error-state retry button already uses, rather than a dead button or an invented picker). The always-expanded recording panel and the standalone sleep-timer button both become tool-tray tiles (EQ propio / Grabar / sleep timer / Compartir), each opening its own bottom sheet. "EQ propio" opens a sheet hosting EcualizadorWidget - the exact same component WU13 restyled for Settings, bound via the existing presetParaEmisora/guardarPresetPorEmisora per-station persistence path. No second editor was created; the multi-device-eq resolution hierarchy is untouched. pantalla_reproductor.dart had zero test coverage before this commit (907 lines) - writing it first surfaced two pre-existing bugs blocking any coverage at all, both fixed: initState called estado.reproducir() directly, which notifies listeners synchronously before its first await and threw "setState() during build" the instant the screen mounted against a fresh Provider tree (fixed via addPostFrameCallback); and the body Column had no scrollable ancestor and overflowed even a generously tall viewport (fixed by wrapping it in a SingleChildScrollView, a real UX improvement and not just a test workaround). The three protected EQ test files (servicio_ecualizador_test.dart, estado_ecualizador_test.dart, servicio_audio_eq_reapply_test.dart) stay unmodified. Full suite: 730/730 green (2 skipped, unchanged), up from 713. size:exception - realized 1,410 changed lines (25 files including this docs update) against the 450-600 forecast: the restructured screen file alone is 658 lines (a near-total rewrite of a 907-line file, not a patch), its new test file (first-ever coverage) is 519 lines, and a new test fake plus a togglePlay() override account for the rest. Not splittable: the restructure, the tool tray, and the EQ-sheet wiring are one cohesive change to one screen.
This commit is contained in:
@@ -701,6 +701,8 @@
|
|||||||
"notPlaying": "Not playing",
|
"notPlaying": "Not playing",
|
||||||
"oneTimeOption": "Once",
|
"oneTimeOption": "Once",
|
||||||
"pausePlaybackTooltip": "Pause playback",
|
"pausePlaybackTooltip": "Pause playback",
|
||||||
|
"playerQualityChangeAction": "Change",
|
||||||
|
"playerToolEqLabel": "Own EQ",
|
||||||
"qualityOriginal": "Original quality: {quality}",
|
"qualityOriginal": "Original quality: {quality}",
|
||||||
"@qualityOriginal": {
|
"@qualityOriginal": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
|
|||||||
@@ -657,6 +657,8 @@
|
|||||||
"notPlaying": "No está reproduciendo",
|
"notPlaying": "No está reproduciendo",
|
||||||
"oneTimeOption": "Una vez",
|
"oneTimeOption": "Una vez",
|
||||||
"pausePlaybackTooltip": "Pausar reproducción",
|
"pausePlaybackTooltip": "Pausar reproducción",
|
||||||
|
"playerQualityChangeAction": "Cambiar",
|
||||||
|
"playerToolEqLabel": "EQ propio",
|
||||||
"qualityOriginal": "Calidad original: {quality}",
|
"qualityOriginal": "Calidad original: {quality}",
|
||||||
"@qualityOriginal": {"placeholders": {"quality": {}}},
|
"@qualityOriginal": {"placeholders": {"quality": {}}},
|
||||||
"qualityUnknown": "Calidad no informada",
|
"qualityUnknown": "Calidad no informada",
|
||||||
|
|||||||
@@ -2480,6 +2480,18 @@ abstract class AppLocalizations {
|
|||||||
/// **'Pausar reproducción'**
|
/// **'Pausar reproducción'**
|
||||||
String get pausePlaybackTooltip;
|
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.
|
/// No description provided for @qualityOriginal.
|
||||||
///
|
///
|
||||||
/// In es, this message translates to:
|
/// In es, this message translates to:
|
||||||
|
|||||||
@@ -1360,6 +1360,12 @@ class AppLocalizationsAr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get pausePlaybackTooltip => 'إيقاف مؤقت';
|
String get pausePlaybackTooltip => 'إيقاف مؤقت';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerQualityChangeAction => 'Cambiar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerToolEqLabel => 'EQ propio';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String qualityOriginal(Object quality) {
|
String qualityOriginal(Object quality) {
|
||||||
return 'الجودة الأصلية: $quality';
|
return 'الجودة الأصلية: $quality';
|
||||||
|
|||||||
@@ -1369,6 +1369,12 @@ class AppLocalizationsBn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get pausePlaybackTooltip => 'প্লেব্যাক বিরতি';
|
String get pausePlaybackTooltip => 'প্লেব্যাক বিরতি';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerQualityChangeAction => 'Cambiar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerToolEqLabel => 'EQ propio';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String qualityOriginal(Object quality) {
|
String qualityOriginal(Object quality) {
|
||||||
return 'মূল মান: $quality';
|
return 'মূল মান: $quality';
|
||||||
|
|||||||
@@ -1377,6 +1377,12 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get pausePlaybackTooltip => 'Wiedergabe pausieren';
|
String get pausePlaybackTooltip => 'Wiedergabe pausieren';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerQualityChangeAction => 'Cambiar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerToolEqLabel => 'EQ propio';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String qualityOriginal(Object quality) {
|
String qualityOriginal(Object quality) {
|
||||||
return 'Originalqualität: $quality';
|
return 'Originalqualität: $quality';
|
||||||
|
|||||||
@@ -1361,6 +1361,12 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get pausePlaybackTooltip => 'Pause playback';
|
String get pausePlaybackTooltip => 'Pause playback';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerQualityChangeAction => 'Change';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerToolEqLabel => 'Own EQ';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String qualityOriginal(Object quality) {
|
String qualityOriginal(Object quality) {
|
||||||
return 'Original quality: $quality';
|
return 'Original quality: $quality';
|
||||||
|
|||||||
@@ -1372,6 +1372,12 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get pausePlaybackTooltip => 'Pausar reproducción';
|
String get pausePlaybackTooltip => 'Pausar reproducción';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerQualityChangeAction => 'Cambiar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerToolEqLabel => 'EQ propio';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String qualityOriginal(Object quality) {
|
String qualityOriginal(Object quality) {
|
||||||
return 'Calidad original: $quality';
|
return 'Calidad original: $quality';
|
||||||
|
|||||||
@@ -1382,6 +1382,12 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get pausePlaybackTooltip => 'Mettre en pause';
|
String get pausePlaybackTooltip => 'Mettre en pause';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerQualityChangeAction => 'Cambiar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerToolEqLabel => 'EQ propio';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String qualityOriginal(Object quality) {
|
String qualityOriginal(Object quality) {
|
||||||
return 'Qualité d’origine : $quality';
|
return 'Qualité d’origine : $quality';
|
||||||
|
|||||||
@@ -1367,6 +1367,12 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get pausePlaybackTooltip => 'प्लेबैक रोकें';
|
String get pausePlaybackTooltip => 'प्लेबैक रोकें';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerQualityChangeAction => 'Cambiar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerToolEqLabel => 'EQ propio';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String qualityOriginal(Object quality) {
|
String qualityOriginal(Object quality) {
|
||||||
return 'मूल गुणवत्ता: $quality';
|
return 'मूल गुणवत्ता: $quality';
|
||||||
|
|||||||
@@ -1373,6 +1373,12 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get pausePlaybackTooltip => 'Jeda pemutaran';
|
String get pausePlaybackTooltip => 'Jeda pemutaran';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerQualityChangeAction => 'Cambiar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerToolEqLabel => 'EQ propio';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String qualityOriginal(Object quality) {
|
String qualityOriginal(Object quality) {
|
||||||
return 'Kualitas asli: $quality';
|
return 'Kualitas asli: $quality';
|
||||||
|
|||||||
@@ -1377,6 +1377,12 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get pausePlaybackTooltip => 'Pausa riproduzione';
|
String get pausePlaybackTooltip => 'Pausa riproduzione';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerQualityChangeAction => 'Cambiar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerToolEqLabel => 'EQ propio';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String qualityOriginal(Object quality) {
|
String qualityOriginal(Object quality) {
|
||||||
return 'Qualità originale: $quality';
|
return 'Qualità originale: $quality';
|
||||||
|
|||||||
@@ -1329,6 +1329,12 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get pausePlaybackTooltip => '再生を一時停止';
|
String get pausePlaybackTooltip => '再生を一時停止';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerQualityChangeAction => 'Cambiar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerToolEqLabel => 'EQ propio';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String qualityOriginal(Object quality) {
|
String qualityOriginal(Object quality) {
|
||||||
return '元の音質: $quality';
|
return '元の音質: $quality';
|
||||||
|
|||||||
@@ -1369,6 +1369,12 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get pausePlaybackTooltip => 'Pausar reprodução';
|
String get pausePlaybackTooltip => 'Pausar reprodução';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerQualityChangeAction => 'Cambiar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerToolEqLabel => 'EQ propio';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String qualityOriginal(Object quality) {
|
String qualityOriginal(Object quality) {
|
||||||
return 'Qualidade original: $quality';
|
return 'Qualidade original: $quality';
|
||||||
|
|||||||
@@ -1373,6 +1373,12 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get pausePlaybackTooltip => 'Пауза';
|
String get pausePlaybackTooltip => 'Пауза';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerQualityChangeAction => 'Cambiar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerToolEqLabel => 'EQ propio';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String qualityOriginal(Object quality) {
|
String qualityOriginal(Object quality) {
|
||||||
return 'Исходное качество: $quality';
|
return 'Исходное качество: $quality';
|
||||||
|
|||||||
@@ -1324,6 +1324,12 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get pausePlaybackTooltip => '暂停播放';
|
String get pausePlaybackTooltip => '暂停播放';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerQualityChangeAction => 'Cambiar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playerToolEqLabel => 'EQ propio';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String qualityOriginal(Object quality) {
|
String qualityOriginal(Object quality) {
|
||||||
return '原始质量:$quality';
|
return '原始质量:$quality';
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:share_plus/share_plus.dart' show Share;
|
||||||
import 'package:shimmer/shimmer.dart';
|
import 'package:shimmer/shimmer.dart';
|
||||||
|
|
||||||
import '../estado/estado_ecualizador.dart';
|
import '../estado/estado_ecualizador.dart';
|
||||||
@@ -12,14 +13,29 @@ import '../servicios/servicio_audio.dart';
|
|||||||
import '../servicios/servicio_timer.dart';
|
import '../servicios/servicio_timer.dart';
|
||||||
import '../tema/pluri_animate.dart';
|
import '../tema/pluri_animate.dart';
|
||||||
import '../tema/pluriwave_theme.dart';
|
import '../tema/pluriwave_theme.dart';
|
||||||
|
import '../widgets/ecualizador_widget.dart';
|
||||||
import '../widgets/pluri_glass_surface.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';
|
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 {
|
class PantallaReproductor extends StatefulWidget {
|
||||||
final Emisora emisora;
|
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<void> Function(String texto)? compartir;
|
||||||
|
|
||||||
|
const PantallaReproductor({super.key, required this.emisora, this.compartir});
|
||||||
|
|
||||||
static Future<void> abrir(BuildContext context, Emisora emisora) {
|
static Future<void> abrir(BuildContext context, Emisora emisora) {
|
||||||
return Navigator.push(
|
return Navigator.push(
|
||||||
@@ -46,18 +62,21 @@ class PantallaReproductor extends StatefulWidget {
|
|||||||
State<PantallaReproductor> createState() => _PantallaReproductorState();
|
State<PantallaReproductor> createState() => _PantallaReproductorState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PantallaReproductorState extends State<PantallaReproductor>
|
class _PantallaReproductorState extends State<PantallaReproductor> {
|
||||||
with SingleTickerProviderStateMixin {
|
late final Future<void> Function(String) _compartir =
|
||||||
late AnimationController _pulseController;
|
widget.compartir ?? (texto) => Share.share(texto);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_pulseController = AnimationController(
|
// Bugfix (surfaced by this screen's first-ever test coverage, WU14):
|
||||||
vsync: this,
|
// EstadoRadio.reproducir() calls notifyListeners() synchronously before
|
||||||
duration: const Duration(seconds: 2),
|
// its first await when no recording is active, which previously threw
|
||||||
);
|
// "setState() or markNeedsBuild() called during build" the instant this
|
||||||
_iniciarReproduccion();
|
// 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<void> _iniciarReproduccion() async {
|
Future<void> _iniciarReproduccion() async {
|
||||||
@@ -67,12 +86,6 @@ class _PantallaReproductorState extends State<PantallaReproductor>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_pulseController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
@@ -87,46 +100,29 @@ class _PantallaReproductorState extends State<PantallaReproductor>
|
|||||||
(e) => e.uuid == emisoraActiva.uuid,
|
(e) => e.uuid == emisoraActiva.uuid,
|
||||||
);
|
);
|
||||||
|
|
||||||
return PluriWaveScaffold(
|
return PluriPushScaffold(
|
||||||
appBar: AppBar(
|
title: emisoraActiva.nombre,
|
||||||
backgroundColor: Colors.transparent,
|
leadingIcon: Icons.keyboard_arrow_down_rounded,
|
||||||
elevation: 0,
|
titleOverride: StreamBuilder<EstadoReproduccion>(
|
||||||
leading: IconButton(
|
stream: estado.estadoStream,
|
||||||
icon: const Icon(Icons.keyboard_arrow_down_rounded, size: 32),
|
builder: (context, snapshot) {
|
||||||
tooltip: l10n.closeAction,
|
final enVivo = snapshot.data == EstadoReproduccion.reproduciendo;
|
||||||
onPressed: () => Navigator.pop(context),
|
return PluriStatusPill(
|
||||||
),
|
icon:
|
||||||
actions: [
|
enVivo
|
||||||
IconButton(
|
? Icons.podcasts_rounded
|
||||||
icon: Icon(
|
: Icons.pause_circle_outline_rounded,
|
||||||
eq.activo ? Icons.equalizer_rounded : Icons.equalizer_outlined,
|
label: enVivo ? l10n.liveNow : l10n.notPlaying,
|
||||||
color: eq.activo ? tokens.warmCoral : null,
|
accent: enVivo ? tokens.liveGreen : 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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Padding(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 8),
|
_ArteReproductor(
|
||||||
_WaveHero(
|
|
||||||
emisora: emisoraActiva,
|
emisora: emisoraActiva,
|
||||||
estadoStream: estado.estadoStream,
|
estadoStream: estado.estadoStream,
|
||||||
).pluriScaleIn(
|
).pluriScaleIn(
|
||||||
@@ -145,23 +141,15 @@ class _PantallaReproductorState extends State<PantallaReproductor>
|
|||||||
maxLines: 2,
|
maxLines: 2,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
).pluriFadeIn(context, delay: const Duration(milliseconds: 150)),
|
).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),
|
const SizedBox(height: 6),
|
||||||
if (emisoraActiva.codec != null || emisoraActiva.bitrate != null)
|
_SubtituloInfo(
|
||||||
Text(
|
emisora: emisoraActiva,
|
||||||
_codecInfo(context, emisoraActiva),
|
).pluriFadeIn(context, delay: const Duration(milliseconds: 190)),
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
const SizedBox(height: 12),
|
||||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.72),
|
_FilaCalidad(
|
||||||
),
|
estado: estado,
|
||||||
).pluriFadeIn(
|
emisora: emisoraActiva,
|
||||||
context,
|
).pluriFadeIn(context, delay: const Duration(milliseconds: 230)),
|
||||||
delay: const Duration(milliseconds: 250),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
PluriGlassSurface(
|
PluriGlassSurface(
|
||||||
borderRadius: BorderRadius.circular(tokens.radiusLg),
|
borderRadius: BorderRadius.circular(tokens.radiusLg),
|
||||||
@@ -177,54 +165,48 @@ class _PantallaReproductorState extends State<PantallaReproductor>
|
|||||||
color: tokens.warmCoral,
|
color: tokens.warmCoral,
|
||||||
altura: 46,
|
altura: 46,
|
||||||
),
|
),
|
||||||
).pluriFadeIn(context, delay: const Duration(milliseconds: 280)),
|
).pluriFadeIn(context, delay: const Duration(milliseconds: 270)),
|
||||||
const Spacer(),
|
const SizedBox(height: 22),
|
||||||
_Controles(
|
_Controles(
|
||||||
estado: estado,
|
estado: estado,
|
||||||
emisora: emisoraActiva,
|
emisora: emisoraActiva,
|
||||||
|
esFavorito: esFavorito,
|
||||||
).pluriFadeSlideIn(
|
).pluriFadeSlideIn(
|
||||||
context,
|
context,
|
||||||
delay: const Duration(milliseconds: 300),
|
delay: const Duration(milliseconds: 310),
|
||||||
beginY: 0.3,
|
beginY: 0.3,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 20),
|
||||||
const _GrabacionWidget().pluriFadeIn(
|
_BandejaHerramientas(
|
||||||
context,
|
|
||||||
delay: const Duration(milliseconds: 360),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 14),
|
|
||||||
_TimerWidget(
|
|
||||||
estado: estado,
|
estado: estado,
|
||||||
).pluriFadeIn(context, delay: const Duration(milliseconds: 400)),
|
eq: eq,
|
||||||
const SizedBox(height: 16),
|
emisora: emisoraActiva,
|
||||||
|
compartir: _compartir,
|
||||||
|
).pluriFadeIn(context, delay: const Duration(milliseconds: 350)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _codecInfo(BuildContext context, Emisora e) {
|
|
||||||
final parts = <String>[];
|
|
||||||
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 Emisora emisora;
|
||||||
final Stream<EstadoReproduccion> estadoStream;
|
final Stream<EstadoReproduccion> estadoStream;
|
||||||
|
|
||||||
const _WaveHero({required this.emisora, required this.estadoStream});
|
const _ArteReproductor({required this.emisora, required this.estadoStream});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final t = context.pluriTokens;
|
final t = context.pluriTokens;
|
||||||
final size = MediaQuery.of(context).size.width * 0.62;
|
final size = MediaQuery.of(context).size.width * 0.62;
|
||||||
|
final radio = BorderRadius.circular(t.radiusLg);
|
||||||
|
|
||||||
return StreamBuilder<EstadoReproduccion>(
|
return StreamBuilder<EstadoReproduccion>(
|
||||||
stream: estadoStream,
|
stream: estadoStream,
|
||||||
@@ -237,6 +219,7 @@ class _WaveHero extends StatelessWidget {
|
|||||||
final hayError = snapshot.data == EstadoReproduccion.error;
|
final hayError = snapshot.data == EstadoReproduccion.error;
|
||||||
|
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
|
key: const Key('player-hero-art'),
|
||||||
width: size + 40,
|
width: size + 40,
|
||||||
height: size + 40,
|
height: size + 40,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
@@ -246,7 +229,7 @@ class _WaveHero extends StatelessWidget {
|
|||||||
width: size + 34,
|
width: size + 34,
|
||||||
height: size + 34,
|
height: size + 34,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
borderRadius: BorderRadius.circular(t.radiusLg + 16),
|
||||||
gradient: RadialGradient(
|
gradient: RadialGradient(
|
||||||
colors: [
|
colors: [
|
||||||
t.electricMagenta.withValues(
|
t.electricMagenta.withValues(
|
||||||
@@ -261,17 +244,18 @@ class _WaveHero extends StatelessWidget {
|
|||||||
width: size + 12,
|
width: size + 12,
|
||||||
height: size + 12,
|
height: size + 12,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
borderRadius: BorderRadius.circular(t.radiusLg + 4),
|
||||||
border: Border.all(color: t.glassBorder),
|
border: Border.all(color: t.glassBorder),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
PluriGlassSurface(
|
PluriGlassSurface(
|
||||||
borderRadius: BorderRadius.circular(size),
|
borderRadius: radio,
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
child: ClipOval(
|
child: ClipRRect(
|
||||||
|
borderRadius: radio,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
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;
|
final Emisora emisora;
|
||||||
const _InfoChips({required this.emisora});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final partes = <String>[
|
||||||
final items = <String>[];
|
if (emisora.pais != null && emisora.pais!.isNotEmpty) emisora.pais!,
|
||||||
if (emisora.pais != null) items.add(emisora.pais!);
|
if (emisora.idioma != null && emisora.idioma!.isNotEmpty) emisora.idioma!,
|
||||||
if (emisora.idioma != null) items.add(emisora.idioma!);
|
];
|
||||||
if ((emisora.bitrate ?? 0) > 0) items.add('${emisora.bitrate} kbps');
|
if (partes.isEmpty) return const SizedBox.shrink();
|
||||||
if (emisora.codec != null) items.add(emisora.codec!.toUpperCase());
|
|
||||||
if (items.isEmpty) return const SizedBox.shrink();
|
|
||||||
|
|
||||||
return Wrap(
|
return Text(
|
||||||
spacing: 8,
|
key: const Key('player-subtitle-line'),
|
||||||
runSpacing: 6,
|
partes.join(' · '),
|
||||||
alignment: WrapAlignment.center,
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
children:
|
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.72),
|
||||||
items
|
),
|
||||||
.map(
|
textAlign: TextAlign.center,
|
||||||
(label) => Chip(
|
maxLines: 1,
|
||||||
label: Text(label),
|
overflow: TextOverflow.ellipsis,
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
backgroundColor: theme.colorScheme.secondaryContainer
|
|
||||||
.withValues(alpha: 0.8),
|
|
||||||
labelStyle: TextStyle(
|
|
||||||
color: theme.colorScheme.onSecondaryContainer,
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList(),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 = <String>[];
|
||||||
|
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 {
|
class _GrabacionWidget extends StatelessWidget {
|
||||||
// Recording state lives in EstadoGrabacion (S4-R2); EstadoRadio no longer
|
// Recording state lives in EstadoGrabacion (S4-R2); EstadoRadio no longer
|
||||||
// notifies on recording progress, so this widget watches the new notifier.
|
// notifies on recording progress, so this widget watches the new notifier.
|
||||||
@@ -656,11 +689,20 @@ const _opciones = [
|
|||||||
_OpcionGrabacion(Duration(minutes: 30)),
|
_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 {
|
class _Controles extends StatelessWidget {
|
||||||
final EstadoRadio estado;
|
final EstadoRadio estado;
|
||||||
final Emisora emisora;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -709,8 +751,32 @@ class _Controles extends StatelessWidget {
|
|||||||
borderRadius: BorderRadius.circular(28),
|
borderRadius: BorderRadius.circular(28),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
children: [
|
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(
|
Semantics(
|
||||||
button: true,
|
button: true,
|
||||||
label: l10n.stopPlaybackTooltip,
|
label: l10n.stopPlaybackTooltip,
|
||||||
@@ -726,7 +792,6 @@ class _Controles extends StatelessWidget {
|
|||||||
onPressed: cargando ? null : estado.detenerReproduccion,
|
onPressed: cargando ? null : estado.detenerReproduccion,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
|
||||||
AnimatedContainer(
|
AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
width: 72,
|
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 {
|
/// One tile of the 4-tile tool tray (WU14: EQ propio / Grabar / sleep timer
|
||||||
final EstadoRadio estado;
|
/// / Compartir), each opening its own bottom sheet (Compartir invokes
|
||||||
const _TimerWidget({required this.estado});
|
/// 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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
final tokens = context.pluriTokens;
|
||||||
|
|
||||||
if (!estado.timer.activo) {
|
return Material(
|
||||||
return TextButton.icon(
|
type: MaterialType.transparency,
|
||||||
icon: const Icon(Icons.bedtime_outlined, size: 18),
|
child: InkWell(
|
||||||
label: Text(AppLocalizations.of(context).sleepTimer),
|
borderRadius: BorderRadius.circular(tokens.radiusSm),
|
||||||
onPressed: () => _mostrarTimerDialog(context),
|
onTap: onTap,
|
||||||
);
|
child: Container(
|
||||||
}
|
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
return StreamBuilder<Duration>(
|
color: tokens.listSurface.withValues(alpha: 0.55),
|
||||||
stream: estado.timer.tiempoRestanteStream,
|
borderRadius: BorderRadius.circular(tokens.radiusSm),
|
||||||
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(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
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<void> 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<EstadoGrabacion>(
|
||||||
|
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<Duration>(
|
||||||
|
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<void> _mostrarHojaEq(
|
||||||
|
BuildContext context,
|
||||||
|
EstadoEcualizador eq,
|
||||||
|
Emisora emisora,
|
||||||
|
) {
|
||||||
|
return showModalBottomSheet<void>(
|
||||||
|
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<void> _mostrarHojaGrabacion(BuildContext context) {
|
||||||
|
return showModalBottomSheet<void>(
|
||||||
|
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<void> _mostrarHojaTimer(BuildContext context, EstadoRadio estado) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
return showModalBottomSheet<void>(
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@
|
|||||||
| 10 | `feat(alarmas): rewrite alarm editor with inline time widget` | 8 | 500-650 | High | **Yes — indivisible new widget**‡ |
|
| 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 |
|
| 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** |
|
| 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§ |
|
| 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 |
|
| 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 |
|
| 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
|
**Modified tests**: `pantalla_reproductor_test.dart` (or equivalent widget test). Three EQ test files must pass
|
||||||
**unmodified**.
|
**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).
|
(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
|
- [x] 14.2 RED — the 4 tool-tray tiles ("EQ propio", "Grabar", sleep-timer value, "Compartir") each open their own
|
||||||
bottom sheet.
|
bottom sheet (Compartir invokes directly — there is no sheet content for a share action).
|
||||||
- [ ] 14.3 RED — opening "EQ propio" for a playing station shows 5 sliders bound to that station's resolved preset,
|
- [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` /
|
and a change round-trips through the existing per-station persistence path (`presetsPorEmisora` /
|
||||||
`presetsMatriz`).
|
`presetsMatriz`).
|
||||||
- [ ] 14.4 GREEN — restructure `pantalla_reproductor.dart`: square art, favorite moved into the transport row,
|
- [x] 14.4 GREEN — restructured `pantalla_reproductor.dart` onto `PluriPushScaffold` (design ADR-2 — this screen is
|
||||||
single subtitle line (collapse the current `_InfoChips` `Wrap`), 4-tile tool-tray grid replacing the
|
the documented single consumer of `titleOverride`, a centered live/not-playing `PluriStatusPill`, and of the
|
||||||
always-expanded recording panel + separate sleep-timer button + EQ toggle.
|
non-default `leadingIcon: keyboard_arrow_down_rounded`): square art (`ClipRRect`, was `ClipOval`), favorite
|
||||||
- [ ] 14.5 GREEN — wire "EQ propio" to a bottom sheet hosting `EcualizadorWidget` bound via
|
moved into the transport row (the old redundant live-indicator dot removed — the AppBar pill already covers
|
||||||
`EstadoEcualizador.presetParaEmisora(uuid)` / `guardarPresetPorEmisora(uuid, ...)`.
|
that signal, so "favorite / stop / play-pause" matches the proposal's own "3 controles" note), single
|
||||||
- [ ] 14.6 GREEN — add the quality row + "Cambiar" action and the "Compartir" tool-tray tile.
|
subtitle line (collapses the old `_InfoChips` `Wrap`; codec/bitrate moved to the new quality row), 4-tile
|
||||||
- [ ] 14.7 REFACTOR — confirm no second EQ editor file was created; confirm the `multi-device-eq` regression
|
tool-tray row replacing the always-expanded recording panel + separate sleep-timer button + EQ toggle. Body
|
||||||
scenarios (device-event resolve-and-apply, first-seen bootstrap, cold start, connect/disconnect/reconnect,
|
wrapped in `SingleChildScrollView` (was a fixed `Column` + `Spacer()`, which overflowed even a generously
|
||||||
toggle-off) still pass unmodified.
|
tall test viewport — see 2 bugfixes below).
|
||||||
- [ ] 14.8 Verify — the 3 EQ test files remain green and unmodified; `EcualizadorWidget` type-identity assertion
|
- [x] 14.5 GREEN — wired "EQ propio" to a bottom sheet hosting `EcualizadorWidget` bound via
|
||||||
passes.
|
`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)
|
## WU15 — Grabaciones library (new list)
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,21 @@ class FakeServicioAudio extends ServicioAudio {
|
|||||||
emitirEstado(EstadoReproduccion.pausado);
|
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<void> togglePlay() async {
|
||||||
|
if (_estadoActual == EstadoReproduccion.reproduciendo) {
|
||||||
|
await pausar();
|
||||||
|
} else {
|
||||||
|
emitirEstado(EstadoReproduccion.reproduciendo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> setVolumen(double vol) async {
|
Future<void> setVolumen(double vol) async {
|
||||||
volumenesAplicados.add(vol);
|
volumenesAplicados.add(vol);
|
||||||
@@ -639,6 +654,55 @@ class FakeServicioGrabacionRadio extends ServicioGrabacionRadio {
|
|||||||
Future<void> dispose() => _controller.close();
|
Future<void> 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<EstadoGrabacionRadio>.broadcast();
|
||||||
|
EstadoGrabacionRadio _estadoActual = const EstadoGrabacionRadio.inactiva();
|
||||||
|
final List<Duration?> duracionesIniciadas = [];
|
||||||
|
Emisora? ultimaEmisoraIniciada;
|
||||||
|
int detenerCalls = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
EstadoGrabacionRadio get estado => _estadoActual;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<EstadoGrabacionRadio> get estadoStream => _controller.stream;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> inicializar() async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> 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<void> detener() async {
|
||||||
|
detenerCalls++;
|
||||||
|
_estadoActual = const EstadoGrabacionRadio.inactiva();
|
||||||
|
_controller.add(_estadoActual);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> dispose() => _controller.close();
|
||||||
|
}
|
||||||
|
|
||||||
Emisora emisoraDemo({
|
Emisora emisoraDemo({
|
||||||
required String uuid,
|
required String uuid,
|
||||||
required String nombre,
|
required String nombre,
|
||||||
|
|||||||
@@ -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<Emisora> favoritosIniciales = const [],
|
||||||
|
Map<String, PresetEcualizador>? 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<void> Function(String)? compartir,
|
||||||
|
}) {
|
||||||
|
return MultiProvider(
|
||||||
|
providers: [
|
||||||
|
ChangeNotifierProvider<EstadoRadio>.value(value: estado),
|
||||||
|
ListenableProvider<EstadoEcualizador>.value(value: estado.ecualizador),
|
||||||
|
ListenableProvider<EstadoGrabacion>.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<void> montarPantalla(
|
||||||
|
WidgetTester tester,
|
||||||
|
EstadoRadio estado, {
|
||||||
|
Emisora? estacion,
|
||||||
|
Future<void> 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<void> presionarActionChip(WidgetTester tester, String label) async {
|
||||||
|
final chip = tester.widget<ActionChip>(
|
||||||
|
find.widgetWithText(ActionChip, label),
|
||||||
|
);
|
||||||
|
chip.onPressed?.call();
|
||||||
|
await tester.pump(const Duration(milliseconds: 300));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same idea for a `FilledButton` inside a bottom sheet.
|
||||||
|
Future<void> presionarFilledButton(WidgetTester tester, String label) async {
|
||||||
|
final boton = tester.widget<FilledButton>(
|
||||||
|
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<Slider>(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,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user