Restructures PantallaInicio's top of screen: a new _EscucharHero (square art, live/offline pill, VisualizadorAudio at barras: 30 / altura: 26 / color: liveGreen, a 5-action transport row - favorite, EQ toggle, stop, play/pause, sleep - plus a tool-tray entry chip opening the full player) replaces the old PluriScreenHeader hero, and a new "Tus emisoras" section (favorites preview, capped, "Ver todas") follows it. Per design ADR-7, EstadoRadio stays the single source of truth: the hero is a StatelessWidget with no cached fields, reading emisoraActual via context.select (uuid-based equality scopes rebuilds to real station changes) and the fast-changing playback status via StreamBuilder, the same pattern _Controles/MiniReproductor already use. The still-present discovery sections (_seccionCercanas onward, including the old grid) are deliberately left in place - WU6 relocates them to Buscar and deletes them from here; removing them now would leave that content nowhere until WU6 lands. MiniReproductor gains a `visible` parameter (default true) and a measured `static const double altura`. app.dart passes `visible: indice != RaizPluriWave.escuchar.index`, hiding it visually only (SizedBox.shrink()) while Escuchar is active, since the hero already shows the same station - the State stays mounted so its didChangeDependencies side effect (configurarLocalizaciones, S3-R3) keeps running regardless of tab. altura was measured empirically (72.0, via tester.getSize) rather than guessed, backing a new derived PluriLayout.escucharBottomChromeInset constant now wired into PantallaInicio's own bottom padding. "Ver todas" switches roots via EstadoNavegacionRaiz.irA(favoritos), verified via a NavigatorObserver asserting the push count is unchanged (switches tabs, does not push). Fixed a pre-existing test-infrastructure gap while writing the anti-cache test: no test in this codebase had ever exercised ServicioAudio.androidAudioSessionIdStream against a bare FakeServicioAudio (pantalla_reproductor.dart has always read it but has no test file at all) - the real getter needs registrarHandler() (main.dart, production only) and threw otherwise. Added an empty stream override to FakeServicioAudio, matching VisualizadorAudio's own documented no-native-session fallback. Tests: 614 -> 618 (2 skipped, unchanged). flutter analyze unchanged at 1 pre-existing info. git diff empty for visualizador_audio.dart and estado_radio.dart - this WU touches neither.
863 lines
29 KiB
Dart
863 lines
29 KiB
Dart
import 'package:cached_network_image/cached_network_image.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:shimmer/shimmer.dart' as shimmer;
|
|
|
|
import '../estado/estado_busqueda.dart';
|
|
import '../estado/estado_ecualizador.dart';
|
|
import '../estado/estado_navegacion.dart';
|
|
import '../estado/estado_radio.dart';
|
|
import '../l10n/display_names.dart';
|
|
import '../l10n/gen/app_localizations.dart';
|
|
import '../modelos/emisora.dart';
|
|
import '../servicios/servicio_audio.dart';
|
|
import '../tema/pluri_animate.dart';
|
|
import '../tema/pluriwave_theme.dart';
|
|
import '../widgets/pluri_glass_surface.dart';
|
|
import '../widgets/pluri_icon.dart';
|
|
import '../widgets/pluri_layout.dart';
|
|
import '../widgets/pluri_premium_widgets.dart';
|
|
import '../widgets/visualizador_audio.dart';
|
|
import 'package:pluriwave/widgets/tarjeta_emisora.dart';
|
|
|
|
import 'pantalla_reproductor.dart';
|
|
import 'reproducir_minimizado.dart';
|
|
|
|
/// Pantalla principal: emisoras populares y por género.
|
|
class PantallaInicio extends StatefulWidget {
|
|
const PantallaInicio({super.key});
|
|
|
|
@override
|
|
State<PantallaInicio> createState() => _PantallaInicioState();
|
|
}
|
|
|
|
class _PantallaInicioState extends State<PantallaInicio> {
|
|
static const _generos = [
|
|
'pop',
|
|
'rock',
|
|
'jazz',
|
|
'classical',
|
|
'electronic',
|
|
'news',
|
|
'talk',
|
|
'hip-hop',
|
|
'country',
|
|
'metal',
|
|
'reggae',
|
|
'latin',
|
|
];
|
|
String? _generoSeleccionado;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// S4-R5: no root watch on EstadoRadio. Every field is consumed through
|
|
// context.select over identity-memoized getters, so audio buffer events
|
|
// (which notify EstadoRadio) no longer rebuild this screen.
|
|
final theme = Theme.of(context);
|
|
final l10n = AppLocalizations.of(context);
|
|
final error = context.select<EstadoRadio, String?>((e) => e.error);
|
|
|
|
return RefreshIndicator(
|
|
onRefresh: () => context.read<EstadoRadio>().cargarPopulares(),
|
|
child: CustomScrollView(
|
|
slivers: [
|
|
// WU5: replaces the old PluriScreenHeader hero. The discovery
|
|
// sections below (_seccionCercanas onward) are deliberately left
|
|
// in place — WU6 relocates them to Buscar and deletes them from
|
|
// here; removing them now would leave that content nowhere until
|
|
// WU6 lands.
|
|
const SliverToBoxAdapter(child: _EscucharHero()),
|
|
SliverToBoxAdapter(child: _seccionTusEmisoras(context, theme, l10n)),
|
|
SliverToBoxAdapter(child: _seccionCercanas(context, theme, l10n)),
|
|
SliverToBoxAdapter(child: _seccionTendencias(context, theme, l10n)),
|
|
SliverToBoxAdapter(child: _chipGeneros(context, theme, l10n)),
|
|
if (error != null)
|
|
SliverToBoxAdapter(
|
|
child: _errorBanner(context, error, theme, l10n),
|
|
),
|
|
SliverPadding(
|
|
// ADR-7(b): the mini player is hidden on this whole screen
|
|
// (app.dart), so its content needs less bottom padding than
|
|
// every other root — escucharBottomChromeInset, not the plain
|
|
// bottomChromeInset every other root/scrollable uses.
|
|
padding: const EdgeInsets.fromLTRB(
|
|
PluriLayout.horizontal,
|
|
0,
|
|
PluriLayout.horizontal,
|
|
PluriLayout.escucharBottomChromeInset,
|
|
),
|
|
sliver: _gridEmisoras(context, l10n),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// WU5 task 5.8: a preview of `listaFavoritos` (capped — full browsing,
|
|
/// filtering, and reordering live on Favoritos itself, WU4), with "Ver
|
|
/// todas" switching the root tab via `EstadoNavegacionRaiz.irA` rather
|
|
/// than pushing a route (`app-navigation-shell` — Root-to-Root Switching
|
|
/// Without Push).
|
|
static const _capTusEmisoras = 6;
|
|
|
|
Widget _seccionTusEmisoras(
|
|
BuildContext context,
|
|
ThemeData theme,
|
|
AppLocalizations l10n,
|
|
) {
|
|
final favoritos = context.select<EstadoRadio, List<Emisora>>(
|
|
(e) => e.listaFavoritos,
|
|
);
|
|
final mostrados = favoritos.take(_capTusEmisoras).toList();
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(
|
|
PluriLayout.horizontal,
|
|
8,
|
|
PluriLayout.horizontal,
|
|
0,
|
|
),
|
|
child: PluriGlassSurface(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
l10n.yourStationsTitle,
|
|
style: theme.textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.w900,
|
|
),
|
|
),
|
|
),
|
|
TextButton(
|
|
onPressed:
|
|
() => context.read<EstadoNavegacionRaiz>().irA(
|
|
RaizPluriWave.favoritos,
|
|
),
|
|
child: Text(l10n.seeAllAction),
|
|
),
|
|
],
|
|
),
|
|
if (mostrados.isEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 4),
|
|
child: Text(
|
|
l10n.favoritesEmptySubtitle,
|
|
style: theme.textTheme.bodySmall,
|
|
),
|
|
)
|
|
else
|
|
SizedBox(
|
|
height: 76,
|
|
child: ListView.separated(
|
|
scrollDirection: Axis.horizontal,
|
|
itemCount: mostrados.length,
|
|
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
|
itemBuilder: (context, i) {
|
|
final emisora = mostrados[i];
|
|
return SizedBox(
|
|
width: 260,
|
|
child: TarjetaEmisora(
|
|
emisora: emisora,
|
|
esCompacta: true,
|
|
onTap: () => reproducirMinimizado(context, emisora),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _seccionCercanas(
|
|
BuildContext context,
|
|
ThemeData theme,
|
|
AppLocalizations l10n,
|
|
) {
|
|
// Nearby stations live in EstadoBusqueda (S4-R3).
|
|
final busqueda = context.watch<EstadoBusqueda>();
|
|
final pais = busqueda.paisCercanoDetectado;
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(
|
|
PluriLayout.horizontal,
|
|
8,
|
|
PluriLayout.horizontal,
|
|
0,
|
|
),
|
|
child: PluriGlassSurface(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
pais == null ? l10n.nearYou : l10n.nearYouInCountry(pais),
|
|
style: theme.textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.w900,
|
|
),
|
|
),
|
|
),
|
|
TextButton.icon(
|
|
onPressed:
|
|
busqueda.cargandoCercanas
|
|
? null
|
|
: busqueda.cargarEmisorasCercanas,
|
|
icon:
|
|
busqueda.cargandoCercanas
|
|
? const SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Icon(Icons.my_location_rounded, size: 18),
|
|
label: Text(l10n.detectAction),
|
|
),
|
|
],
|
|
),
|
|
if (busqueda.errorCercanas != null)
|
|
Text(
|
|
busqueda.errorCercanas!,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.error,
|
|
),
|
|
),
|
|
if (busqueda.cercanas.isNotEmpty) ...[
|
|
const SizedBox(height: 8),
|
|
SizedBox(
|
|
height: 76,
|
|
child: ListView.separated(
|
|
scrollDirection: Axis.horizontal,
|
|
itemCount: busqueda.cercanas.length,
|
|
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
|
itemBuilder: (context, i) {
|
|
final emisora = busqueda.cercanas[i];
|
|
return SizedBox(
|
|
width: 260,
|
|
child: TarjetaEmisora(
|
|
emisora: emisora,
|
|
esCompacta: true,
|
|
onTap: () => reproducirMinimizado(context, emisora),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _seccionTendencias(
|
|
BuildContext context,
|
|
ThemeData theme,
|
|
AppLocalizations l10n,
|
|
) {
|
|
final cargando = context.select<EstadoRadio, bool>(
|
|
(e) => e.cargandoPopulares,
|
|
);
|
|
final tendencias = context.select<EstadoRadio, List<Emisora>>(
|
|
(e) => e.tendencias,
|
|
);
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(
|
|
PluriLayout.horizontal,
|
|
8,
|
|
PluriLayout.horizontal,
|
|
0,
|
|
),
|
|
child: PluriGlassSurface(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(l10n.liveRadar, style: theme.textTheme.titleMedium),
|
|
const SizedBox(height: 8),
|
|
SizedBox(
|
|
height: 56,
|
|
child:
|
|
cargando
|
|
? ListView.separated(
|
|
scrollDirection: Axis.horizontal,
|
|
itemCount: 5,
|
|
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
|
itemBuilder: (_, __) => _ChipShimmer(theme: theme),
|
|
)
|
|
: ListView.separated(
|
|
scrollDirection: Axis.horizontal,
|
|
itemCount: tendencias.length,
|
|
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
|
itemBuilder: (context, i) {
|
|
final e = tendencias[i];
|
|
return ActionChip(
|
|
avatar: const Icon(
|
|
Icons.graphic_eq_rounded,
|
|
size: 18,
|
|
),
|
|
label: Text(e.nombre, maxLines: 1),
|
|
onPressed: () => reproducirMinimizado(context, e),
|
|
).pluriFadeIn(
|
|
context,
|
|
delay: Duration(milliseconds: i * 50),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _chipGeneros(
|
|
BuildContext context,
|
|
ThemeData theme,
|
|
AppLocalizations l10n,
|
|
) {
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(
|
|
PluriLayout.horizontal,
|
|
16,
|
|
PluriLayout.horizontal,
|
|
8,
|
|
),
|
|
child: PluriGlassSurface(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(l10n.genresTitle, style: theme.textTheme.titleMedium),
|
|
const SizedBox(height: 8),
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 4,
|
|
children:
|
|
_generos.map((g) {
|
|
final seleccionado = _generoSeleccionado == g;
|
|
return FilterChip(
|
|
label: Text(_genreName(l10n, g)),
|
|
selected: seleccionado,
|
|
onSelected: (_) {
|
|
setState(() {
|
|
_generoSeleccionado = seleccionado ? null : g;
|
|
});
|
|
if (!seleccionado) {
|
|
context.read<EstadoBusqueda>().buscar(tag: g);
|
|
} else {
|
|
context.read<EstadoRadio>().cargarPopulares();
|
|
}
|
|
},
|
|
);
|
|
}).toList(),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _errorBanner(
|
|
BuildContext context,
|
|
String error,
|
|
ThemeData theme,
|
|
AppLocalizations l10n,
|
|
) {
|
|
return Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: PluriGlassSurface(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.wifi_off, color: theme.colorScheme.error),
|
|
const SizedBox(width: 8),
|
|
Expanded(child: Text(error)),
|
|
TextButton(
|
|
onPressed: () => context.read<EstadoRadio>().cargarPopulares(),
|
|
child: Text(l10n.retryAction),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _gridEmisoras(BuildContext context, AppLocalizations l10n) {
|
|
final porGenero = _generoSeleccionado != null;
|
|
final emisoras =
|
|
porGenero
|
|
? context.select<EstadoBusqueda, List<Emisora>>((b) => b.resultados)
|
|
: context.select<EstadoRadio, List<Emisora>>(
|
|
(e) => e.emisorasInicio,
|
|
);
|
|
final cargando =
|
|
context.select<EstadoRadio, bool>((e) => e.cargandoPopulares) ||
|
|
(porGenero && context.select<EstadoBusqueda, bool>((b) => b.cargando));
|
|
|
|
if (cargando) {
|
|
return SliverGrid(
|
|
delegate: SliverChildBuilderDelegate(
|
|
(_, __) => const TarjetaEmisoraShimmer(),
|
|
childCount: 12,
|
|
),
|
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
|
crossAxisCount: 2,
|
|
childAspectRatio: 0.78,
|
|
crossAxisSpacing: 12,
|
|
mainAxisSpacing: 12,
|
|
),
|
|
);
|
|
}
|
|
|
|
if (emisoras.isEmpty) {
|
|
return SliverFillRemaining(
|
|
child: PluriEmptyState(
|
|
glyph: PluriIconGlyph.home,
|
|
title: l10n.noStationsAvailable,
|
|
subtitle: l10n.noStationsAvailableSubtitle,
|
|
),
|
|
);
|
|
}
|
|
|
|
return SliverGrid(
|
|
delegate: SliverChildBuilderDelegate(
|
|
(context, i) => TarjetaEmisora(
|
|
emisora: emisoras[i],
|
|
onTap: () => reproducirMinimizado(context, emisoras[i]),
|
|
).pluriFadeSlideIn(
|
|
context,
|
|
delay: Duration(milliseconds: i * 30),
|
|
beginY: 0.1,
|
|
),
|
|
childCount: emisoras.length,
|
|
),
|
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
|
crossAxisCount: 2,
|
|
childAspectRatio: 0.78,
|
|
crossAxisSpacing: 12,
|
|
mainAxisSpacing: 12,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
String _genreName(AppLocalizations l10n, String tag) => switch (tag) {
|
|
'pop' => l10n.genrePop,
|
|
'rock' => l10n.genreRock,
|
|
'jazz' => l10n.genreJazz,
|
|
'classical' => l10n.genreClassical,
|
|
'electronic' => l10n.genreElectronic,
|
|
'news' => l10n.genreNews,
|
|
'talk' => l10n.genreTalk,
|
|
'hip-hop' => l10n.genreHipHop,
|
|
'country' => l10n.genreCountry,
|
|
'metal' => l10n.genreMetal,
|
|
'reggae' => l10n.genreReggae,
|
|
'latin' => l10n.genreLatin,
|
|
_ => tag,
|
|
};
|
|
|
|
class _ChipShimmer extends StatelessWidget {
|
|
final ThemeData theme;
|
|
const _ChipShimmer({required this.theme});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return shimmer.Shimmer.fromColors(
|
|
baseColor: theme.colorScheme.surfaceContainerHighest,
|
|
highlightColor: theme.colorScheme.surface,
|
|
child: Container(
|
|
width: 120,
|
|
height: 56,
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(20),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// WU5, design ADR-7: the Escuchar embedded player. `EstadoRadio` is the
|
|
/// single source of truth (`emisoraActual` already feeds `MiniReproductor`
|
|
/// and `PantallaReproductor`) — this hero is a third VIEW, never a third
|
|
/// STATE.
|
|
///
|
|
/// Binding rules this class exists to enforce:
|
|
/// 1. StatelessWidget — no cached `Emisora`, no local playback flag.
|
|
/// 2. Reads use `context.select` per scalar (here, `emisoraActual` itself —
|
|
/// `Emisora`'s own `==`/`hashCode` are uuid-based, so this only rebuilds
|
|
/// the hero when the STATION actually changes, not on every audio buffer
|
|
/// event `EstadoRadio` also notifies on). Fast-changing playback status
|
|
/// (`EstadoReproduccion`) is read via `StreamBuilder` instead, the same
|
|
/// pattern `_Controles`/`MiniReproductor` already use, so status ticks
|
|
/// don't even reach this widget's own rebuild path.
|
|
/// 3. Transport calls the SAME `EstadoRadio`/`EstadoEcualizador` methods the
|
|
/// full player calls — no new playback methods.
|
|
/// 4. `VisualizadorAudio` is reused UNCHANGED, just re-parameterised
|
|
/// (`barras: 30`, `altura: 26`, `color: liveGreen`).
|
|
class _EscucharHero extends StatelessWidget {
|
|
const _EscucharHero();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
final theme = Theme.of(context);
|
|
final emisora = context.select<EstadoRadio, Emisora?>(
|
|
(e) => e.emisoraActual,
|
|
);
|
|
|
|
if (emisora == null) {
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(
|
|
PluriLayout.horizontal,
|
|
8,
|
|
PluriLayout.horizontal,
|
|
0,
|
|
),
|
|
child: PluriGlassSurface(
|
|
padding: const EdgeInsets.all(20),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
l10n.nothingPlayingTitle,
|
|
style: theme.textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.w900,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
l10n.nothingPlayingSubtitle,
|
|
style: theme.textTheme.bodySmall,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
final estado = context.read<EstadoRadio>();
|
|
final stationName = localizedStationName(l10n, emisora.nombre);
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(
|
|
PluriLayout.horizontal,
|
|
8,
|
|
PluriLayout.horizontal,
|
|
0,
|
|
),
|
|
child: PluriGlassSurface(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_ArteEscuchar(emisora: emisora),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
StreamBuilder<EstadoReproduccion>(
|
|
stream: estado.estadoStream,
|
|
builder: (context, snap) {
|
|
final s = snap.data ?? EstadoReproduccion.detenido;
|
|
final enVivo = s == EstadoReproduccion.reproduciendo;
|
|
return PluriStatusPill(
|
|
icon:
|
|
enVivo
|
|
? Icons.podcasts_rounded
|
|
: Icons.pause_circle_outline_rounded,
|
|
label: enVivo ? l10n.liveNow : l10n.notPlaying,
|
|
accent:
|
|
enVivo ? context.pluriTokens.liveGreen : null,
|
|
);
|
|
},
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
stationName,
|
|
style: theme.textTheme.titleLarge?.copyWith(
|
|
fontWeight: FontWeight.w800,
|
|
),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
const SizedBox(height: 8),
|
|
VisualizadorAudio(
|
|
estadoStream: estado.estadoStream,
|
|
androidAudioSessionIdStream:
|
|
estado.audio.androidAudioSessionIdStream,
|
|
barras: 30,
|
|
altura: 26,
|
|
color: context.pluriTokens.liveGreen,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 14),
|
|
_FilaTransporteEscuchar(emisora: emisora),
|
|
const SizedBox(height: 10),
|
|
Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: OutlinedButton.icon(
|
|
icon: const Icon(Icons.tune_rounded, size: 18),
|
|
label: Text(l10n.openFullPlayerTooltip),
|
|
onPressed: () => PantallaReproductor.abrir(context, emisora),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Square art (the design's requested shape for the Escuchar hero — the
|
|
/// full player's own `_WaveHero`, `pantalla_reproductor.dart`, stays
|
|
/// circular and unchanged).
|
|
class _ArteEscuchar extends StatelessWidget {
|
|
const _ArteEscuchar({required this.emisora});
|
|
|
|
final Emisora emisora;
|
|
|
|
static const _lado = 84.0;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final radius = BorderRadius.circular(context.pluriTokens.radiusMd);
|
|
return PluriGlassSurface(
|
|
padding: EdgeInsets.zero,
|
|
borderRadius: radius,
|
|
child: SizedBox(
|
|
width: _lado,
|
|
height: _lado,
|
|
child: ClipRRect(
|
|
borderRadius: radius,
|
|
child:
|
|
(emisora.favicon != null && emisora.favicon!.isNotEmpty)
|
|
? CachedNetworkImage(
|
|
imageUrl: emisora.favicon!,
|
|
fit: BoxFit.cover,
|
|
placeholder: (_, __) => _shimmerCuadrado(theme),
|
|
errorWidget: (_, __, ___) => _iconoFallback(theme),
|
|
)
|
|
: _iconoFallback(theme),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _shimmerCuadrado(ThemeData theme) => shimmer.Shimmer.fromColors(
|
|
baseColor: theme.colorScheme.surfaceContainerHighest,
|
|
highlightColor: theme.colorScheme.surface,
|
|
child: Container(color: theme.colorScheme.surfaceContainerHighest),
|
|
);
|
|
|
|
Widget _iconoFallback(ThemeData theme) => Container(
|
|
color: theme.colorScheme.primaryContainer,
|
|
child: Icon(
|
|
Icons.radio_rounded,
|
|
size: 36,
|
|
color: theme.colorScheme.onPrimaryContainer,
|
|
),
|
|
);
|
|
}
|
|
|
|
/// The hero's transport row — favorite / EQ toggle / stop / play-pause
|
|
/// (primary) / sleep, in that order, with sleep as the documented 5th
|
|
/// action. Every action calls an EXISTING `EstadoRadio`/`EstadoEcualizador`
|
|
/// method — no new playback surface (ADR-7 rule 3).
|
|
class _FilaTransporteEscuchar extends StatelessWidget {
|
|
const _FilaTransporteEscuchar({required this.emisora});
|
|
|
|
final Emisora emisora;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
final t = context.pluriTokens;
|
|
final estado = context.read<EstadoRadio>();
|
|
final esFavorito = context.select<EstadoRadio, bool>(
|
|
(e) => e.listaFavoritos.any((x) => x.uuid == emisora.uuid),
|
|
);
|
|
final eqActivo = context.select<EstadoEcualizador, bool>((e) => e.activo);
|
|
final timerActivo = context.select<EstadoRadio, bool>(
|
|
(e) => e.timer.activo,
|
|
);
|
|
|
|
return Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
IconButton(
|
|
tooltip:
|
|
esFavorito
|
|
? l10n.favoritesRemoveTooltip
|
|
: l10n.favoritesAddTooltip,
|
|
icon: Icon(
|
|
esFavorito
|
|
? Icons.favorite_rounded
|
|
: Icons.favorite_outline_rounded,
|
|
color: esFavorito ? Theme.of(context).colorScheme.error : null,
|
|
),
|
|
onPressed: () => estado.toggleFavorito(emisora),
|
|
),
|
|
IconButton(
|
|
tooltip: eqActivo ? l10n.equalizerDisable : l10n.equalizerEnable,
|
|
icon: Icon(
|
|
eqActivo ? Icons.equalizer_rounded : Icons.equalizer_outlined,
|
|
color: eqActivo ? t.warmCoral : null,
|
|
),
|
|
onPressed:
|
|
() => context.read<EstadoEcualizador>().cambiarActivo(!eqActivo),
|
|
),
|
|
StreamBuilder<EstadoReproduccion>(
|
|
stream: estado.estadoStream,
|
|
builder: (context, snap) {
|
|
final s = snap.data ?? EstadoReproduccion.detenido;
|
|
final cargando =
|
|
s == EstadoReproduccion.cargando ||
|
|
s == EstadoReproduccion.reconectando;
|
|
return IconButton(
|
|
tooltip: l10n.stopAction,
|
|
icon: const Icon(Icons.stop_circle_outlined),
|
|
onPressed: cargando ? null : estado.detenerReproduccion,
|
|
);
|
|
},
|
|
),
|
|
StreamBuilder<EstadoReproduccion>(
|
|
stream: estado.estadoStream,
|
|
builder: (context, snap) {
|
|
final s = snap.data ?? EstadoReproduccion.detenido;
|
|
final reproduciendo = s == EstadoReproduccion.reproduciendo;
|
|
final cargando =
|
|
s == EstadoReproduccion.cargando ||
|
|
s == EstadoReproduccion.reconectando;
|
|
return SizedBox(
|
|
width: 56,
|
|
height: 56,
|
|
child: FilledButton(
|
|
style: FilledButton.styleFrom(
|
|
shape: const CircleBorder(),
|
|
padding: EdgeInsets.zero,
|
|
),
|
|
onPressed:
|
|
cargando
|
|
? null
|
|
: () {
|
|
if (reproduciendo ||
|
|
s == EstadoReproduccion.pausado) {
|
|
estado.togglePlay();
|
|
} else {
|
|
estado.reproducir(emisora);
|
|
}
|
|
},
|
|
child:
|
|
cargando
|
|
? const SizedBox(
|
|
width: 22,
|
|
height: 22,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: Icon(
|
|
reproduciendo
|
|
? Icons.pause_rounded
|
|
: Icons.play_arrow_rounded,
|
|
size: 28,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
IconButton(
|
|
tooltip: l10n.sleepTimer,
|
|
icon: Icon(
|
|
Icons.bedtime_rounded,
|
|
color: timerActivo ? t.warmCoral : null,
|
|
),
|
|
onPressed: () => _mostrarTimerSheet(context, estado, l10n),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Future<void> _mostrarTimerSheet(
|
|
BuildContext context,
|
|
EstadoRadio estado,
|
|
AppLocalizations l10n,
|
|
) {
|
|
return showModalBottomSheet(
|
|
context: context,
|
|
showDragHandle: true,
|
|
builder:
|
|
(ctx) => SafeArea(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
l10n.sleepTimer,
|
|
style: Theme.of(ctx).textTheme.titleLarge,
|
|
),
|
|
const SizedBox(height: 16),
|
|
if (estado.timer.activo)
|
|
FilledButton.tonal(
|
|
onPressed: () {
|
|
estado.cancelarTimer();
|
|
Navigator.pop(ctx);
|
|
},
|
|
child: Text(l10n.cancelTimer),
|
|
)
|
|
else
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: [
|
|
for (final segundos in estado.timerSuenoPresetsSegundos)
|
|
ActionChip(
|
|
label: Text(_formatearMinutos(l10n, segundos)),
|
|
onPressed: () {
|
|
estado.iniciarTimerDuracion(
|
|
Duration(seconds: segundos),
|
|
);
|
|
Navigator.pop(ctx);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
String _formatearMinutos(AppLocalizations l10n, int segundos) {
|
|
final d = Duration(seconds: segundos);
|
|
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
|
|
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
|
|
if (d.inHours > 0) {
|
|
return l10n.durationHoursMinutesSeconds(d.inHours, m, s);
|
|
}
|
|
return d.inSeconds.remainder(60) == 0
|
|
? l10n.durationMinutesOnly(d.inMinutes)
|
|
: l10n.durationMinutesSeconds(d.inMinutes, s);
|
|
}
|
|
}
|