Audit S7 plus the open items on screens 1-3. Escuchar gains the prototype's own 52px "now listening" eyebrow header (t4:53), which is structurally different from the 56px title row every other root uses (t4:325) -- the earlier wiring test asserted PluriRootHeader on all five roots, an over-broad premise now corrected to guard what actually holds: no AppBar, and the sleep-timer action still reachable.
933 lines
34 KiB
Dart
933 lines
34 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_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/pluriwave_theme.dart';
|
|
import '../widgets/pluri_glass_surface.dart';
|
|
import '../widgets/pluri_layout.dart';
|
|
import '../widgets/pluri_premium_widgets.dart';
|
|
import '../widgets/pluri_sleep_timer_sheet.dart';
|
|
import '../widgets/visualizador_audio.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> {
|
|
@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);
|
|
|
|
// Audit 1.1 (t4 lines 49-51): a full-bleed art/gradient/noise wash sits
|
|
// BEHIND the whole screen, not just inside the hero card -- the Stack
|
|
// wrapper is new; everything that used to be the bare CustomScrollView
|
|
// return value is unchanged below.
|
|
return Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
const _FondoEscuchar(),
|
|
CustomScrollView(
|
|
slivers: [
|
|
// Audit 1.2 (t4 line 53): unlike the other 3 roots' plain 56px
|
|
// PluriRootHeader, Escuchar's own header is a 52px "Now
|
|
// listening" eyebrow row -- the tab bar already identifies this
|
|
// destination, so it never repeats "Escuchar" as a title.
|
|
SliverToBoxAdapter(
|
|
child: _CabeceraEscuchar(
|
|
onSleepTimer: () => showPluriSleepTimerSheet(context),
|
|
),
|
|
),
|
|
// WU5 built the hero; WU6 relocated the discovery sections that
|
|
// used to follow it (_seccionCercanas, _seccionTendencias,
|
|
// _chipGeneros, _errorBanner, the browse grid) into
|
|
// PantallaBuscar's landing state and DELETED them here (this WU's
|
|
// own task 6.5 — completing WU5's task 5.9 deferral). Escuchar's
|
|
// content is now just the hero and the favorites preview below;
|
|
// pull-to-refresh was dropped along with the grid it refreshed —
|
|
// the retry button that used to live in the (now relocated) error
|
|
// banner already covers manual recovery on Buscar.
|
|
const SliverToBoxAdapter(child: _EscucharHero()),
|
|
SliverToBoxAdapter(
|
|
child: _seccionTusEmisoras(context, theme, l10n),
|
|
),
|
|
// 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.
|
|
const SliverToBoxAdapter(
|
|
child: SizedBox(height: PluriLayout.escucharBottomChromeInset),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// 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).
|
|
///
|
|
/// Audit 1.10 (t4 lines 82-88): the prototype shows a 2-column grid of 8,
|
|
/// not a 6-capped horizontal strip.
|
|
static const _capTusEmisoras = 8;
|
|
|
|
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: Row(
|
|
children: [
|
|
Flexible(
|
|
child: Text(
|
|
l10n.yourStationsTitle,
|
|
// S7 (visual fidelity, t4 line 80): the in-page
|
|
// section heading style (15/w800/ls-.2), not
|
|
// titleMedium(16)/w900.
|
|
style: context.pluriType.inPageSectionHeading,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
// Audit 1.11 (t4 line 80): a count badge next to the
|
|
// section title — the TOTAL favorite count, not the
|
|
// 8-capped grid size shown below it.
|
|
if (favoritos.isNotEmpty) ...[
|
|
const SizedBox(width: 10),
|
|
_InsigniaRecuento(cuenta: favoritos.length),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
// Audit 1.12 (t4 line 80): "Ver todas" is plain 12px/w800
|
|
// brand-teal text — not a Material TextButton with its own
|
|
// padding and splash.
|
|
Semantics(
|
|
button: true,
|
|
child: GestureDetector(
|
|
onTap:
|
|
() => context.read<EstadoNavegacionRaiz>().irA(
|
|
RaizPluriWave.favoritos,
|
|
),
|
|
child: Text(
|
|
l10n.seeAllAction,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w800,
|
|
color: context.pluriTokens.electricMagenta,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
if (mostrados.isEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 4),
|
|
child: Text(
|
|
l10n.favoritesEmptySubtitle,
|
|
style: theme.textTheme.bodySmall,
|
|
),
|
|
)
|
|
else
|
|
// Audit 1.10 (t4 lines 82-88): a 2-column grid, gap 10 — was a
|
|
// horizontal strip of 260px-wide compact rows.
|
|
GridView.builder(
|
|
padding: EdgeInsets.zero,
|
|
shrinkWrap: true,
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
itemCount: mostrados.length,
|
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
|
crossAxisCount: 2,
|
|
mainAxisSpacing: 10,
|
|
crossAxisSpacing: 10,
|
|
childAspectRatio: 2.6,
|
|
),
|
|
itemBuilder: (context, i) {
|
|
final emisora = mostrados[i];
|
|
return _CeldaTusEmisoras(
|
|
emisora: emisora,
|
|
onTap: () => reproducirMinimizado(context, emisora),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Audit 1.1 (t4 lines 49-51): a full-bleed backdrop behind the ENTIRE
|
|
/// screen -- 400px of the current station's own art at 50% opacity, a
|
|
/// gradient fading it into the page background by 430px, and a faint noise
|
|
/// texture. Reads `emisoraActual` ITSELF (not a parent watch), so this stays
|
|
/// the only new subscriber -- `_PantallaInicioState.build()` keeps its
|
|
/// documented S4-R5 "no root watch" rule. Renders the gradient/noise even
|
|
/// with nothing playing (there is simply no image layer to show then) so
|
|
/// the page keeps the same base depth in every state.
|
|
class _FondoEscuchar extends StatelessWidget {
|
|
const _FondoEscuchar();
|
|
|
|
static const double _altoArte = 400;
|
|
static const double _altoDegradado = 430;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final emisora = context.select<EstadoRadio, Emisora?>(
|
|
(e) => e.emisoraActual,
|
|
);
|
|
final tokens = context.pluriTokens;
|
|
final favicon = emisora?.favicon;
|
|
|
|
return IgnorePointer(
|
|
key: const ValueKey('escuchar-background-art'),
|
|
child: Stack(
|
|
children: [
|
|
if (favicon != null && favicon.isNotEmpty)
|
|
SizedBox(
|
|
width: double.infinity,
|
|
height: _altoArte,
|
|
child: Opacity(
|
|
opacity: 0.5,
|
|
child: CachedNetworkImage(
|
|
imageUrl: favicon,
|
|
fit: BoxFit.cover,
|
|
errorWidget: (_, __, ___) => const SizedBox.shrink(),
|
|
),
|
|
),
|
|
),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
height: _altoDegradado,
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [
|
|
tokens.deepViolet.withValues(alpha: 0.4),
|
|
tokens.deepViolet.withValues(alpha: 0.86),
|
|
tokens.deepViolet,
|
|
],
|
|
stops: const [0, 0.58, 1],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
height: _altoDegradado,
|
|
child: Opacity(
|
|
opacity: 0.05,
|
|
child: Image.asset(
|
|
'assets/images/noise_soft.png',
|
|
fit: BoxFit.cover,
|
|
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Audit 1.2 (t4 line 53): Escuchar's own header -- 52px, an eyebrow-styled
|
|
/// "Now listening" label (not a screen title -- the tab bar already
|
|
/// identifies this destination) and a single trailing action.
|
|
///
|
|
/// The prototype also draws a `cast` icon here. This app has no casting
|
|
/// service anywhere in `lib/servicios` (checked before writing this) --
|
|
/// inventing a Chromecast-style button with no backing capability would fail
|
|
/// the same "don't invent a capability absent from the domain" test WU5/WU9
|
|
/// already established elsewhere in this app, so it is omitted, not faked.
|
|
/// The ONE real action this row keeps is the sleep timer, which used to be
|
|
/// [PluriRootHeader]'s bedtime button on this exact screen.
|
|
class _CabeceraEscuchar extends StatelessWidget {
|
|
const _CabeceraEscuchar({required this.onSleepTimer});
|
|
|
|
final VoidCallback onSleepTimer;
|
|
|
|
static const double altura = 52;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
return SizedBox(
|
|
height: altura,
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(20, 0, 10, 0),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
l10n.nowListeningLabel,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w800,
|
|
letterSpacing: 0.3,
|
|
color: const Color(0xFFF2F7FA).withValues(alpha: 0.55),
|
|
),
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.bedtime_outlined),
|
|
iconSize: 23,
|
|
tooltip: l10n.sleepTimer,
|
|
onPressed: onSleepTimer,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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(
|
|
// S3 (Tier 1 visual fidelity): the active/now-playing card — the
|
|
// system rule's other named exception to the opaque default.
|
|
glass: true,
|
|
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,
|
|
// Audit 1.5 (t4 line 60): 24/w800/ls-.5/height 1.12
|
|
// -- titleLarge is 22 with neither letter-spacing
|
|
// nor an explicit line height.
|
|
style: theme.textTheme.titleLarge?.copyWith(
|
|
fontSize: 24,
|
|
fontWeight: FontWeight.w800,
|
|
letterSpacing: -0.5,
|
|
height: 1.12,
|
|
),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
if (_metaEscuchar(emisora) case final meta?) ...[
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
meta,
|
|
key: const ValueKey('escuchar-hero-meta'),
|
|
style: TextStyle(
|
|
fontSize: 12.5,
|
|
fontWeight: FontWeight.w600,
|
|
color: theme.colorScheme.onSurface.withValues(
|
|
alpha: 0.62,
|
|
),
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
const SizedBox(height: 8),
|
|
VisualizadorAudio(
|
|
estadoStream: estado.estadoStream,
|
|
androidAudioSessionIdStream:
|
|
estado.audio.androidAudioSessionIdStream,
|
|
barras: 30,
|
|
altura: 26,
|
|
color: context.pluriTokens.liveGreen,
|
|
// Audit 1.7 (t4 lines 66-68): 30 discrete
|
|
// bottom-anchored bars, not a continuous stroke.
|
|
barrasDiscretas: true,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 14),
|
|
_FilaTransporteEscuchar(emisora: emisora),
|
|
const SizedBox(height: 10),
|
|
// Audit 1.9 (t4 line 78): a CENTRED pill -- padding:9px 15px,
|
|
// radius:999, rgba(255,255,255,.07) + border .1, 12px/w700,
|
|
// icon expand_less 17px -- not a left-aligned OutlinedButton
|
|
// with tune_rounded at 18px.
|
|
Center(
|
|
child: Material(
|
|
type: MaterialType.transparency,
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(999),
|
|
onTap: () => PantallaReproductor.abrir(context, emisora),
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withValues(alpha: 0.07),
|
|
borderRadius: BorderRadius.circular(999),
|
|
border: Border.all(
|
|
color: Colors.white.withValues(alpha: 0.1),
|
|
),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 15,
|
|
vertical: 9,
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Icons.expand_less_rounded,
|
|
size: 17,
|
|
color: theme.colorScheme.onSurface.withValues(
|
|
alpha: 0.78,
|
|
),
|
|
),
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
l10n.openFullPlayerTooltip,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w700,
|
|
color: theme.colorScheme.onSurface.withValues(
|
|
alpha: 0.78,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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;
|
|
|
|
/// Audit 1.3: the prototype's Escuchar hero art is 132 (t4 line 56), and
|
|
/// its corner radius is 24 rather than the shared `radiusMd`.
|
|
static const _lado = 132.0;
|
|
static const _radio = 24.0;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final radius = BorderRadius.circular(_radio);
|
|
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(
|
|
// Audit 1.8 (t4 line 73): 72x72 with a 38px icon, not 56x56
|
|
// with a 28px icon.
|
|
width: 72,
|
|
height: 72,
|
|
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: 38,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// Audit 1.6 (t4 line 62): "género · país · kbps" built ONLY from fields
|
|
/// [Emisora] already carries (`tags`/`pais`/`bitrate`) — no new fields, no
|
|
/// service calls. Whatever a station lacks is omitted gracefully; this
|
|
/// never renders a stray leading/trailing/doubled " · ".
|
|
String? _metaEscuchar(Emisora emisora) {
|
|
final partes = <String>[
|
|
if (emisora.generos.isNotEmpty) emisora.generos.first,
|
|
if (emisora.pais != null && emisora.pais!.isNotEmpty) emisora.pais!,
|
|
if (emisora.bitrate != null && emisora.bitrate! > 0)
|
|
'${emisora.bitrate} kbps',
|
|
];
|
|
return partes.isEmpty ? null : partes.join(' · ');
|
|
}
|
|
|
|
/// Audit 1.11 (t4 line 80): the pill badge next to "Tus emisoras" showing
|
|
/// the total favorite count — `rgba(255,255,255,.08)` fill, 11px/w800/60%.
|
|
class _InsigniaRecuento extends StatelessWidget {
|
|
const _InsigniaRecuento({required this.cuenta});
|
|
|
|
final int cuenta;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withValues(alpha: 0.08),
|
|
borderRadius: BorderRadius.circular(999),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
|
child: Text(
|
|
'$cuenta',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w800,
|
|
color: const Color(0xFFF2F7FA).withValues(alpha: 0.6),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Audit 1.10 (t4 lines 82-88): the "Tus emisoras" grid cell — a 44px
|
|
/// square thumbnail (radius 11), name (13/w700/lh1.2) and genre
|
|
/// (11/55%). Deliberately NOT `TarjetaEmisora(esCompacta: true)`: that
|
|
/// widget always renders a favorite button and a live badge, neither of
|
|
/// which the prototype's grid cell draws (t4 line 84-87 is art + two text
|
|
/// lines, nothing else).
|
|
class _CeldaTusEmisoras extends StatelessWidget {
|
|
const _CeldaTusEmisoras({required this.emisora, required this.onTap});
|
|
|
|
final Emisora emisora;
|
|
final VoidCallback onTap;
|
|
|
|
static const _ladoArte = 44.0;
|
|
static const _radioArte = 11.0;
|
|
static const _radioCelda = 16.0;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
final theme = Theme.of(context);
|
|
final stationName = localizedStationName(l10n, emisora.nombre);
|
|
final genero = emisora.generos.isNotEmpty ? emisora.generos.first : null;
|
|
|
|
return Semantics(
|
|
button: true,
|
|
label: l10n.stationSemanticLabel(stationName),
|
|
child: PluriGlassSurface(
|
|
borderRadius: BorderRadius.circular(_radioCelda),
|
|
padding: const EdgeInsets.all(8),
|
|
child: Material(
|
|
type: MaterialType.transparency,
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(_radioCelda),
|
|
onTap: onTap,
|
|
child: Row(
|
|
children: [
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(_radioArte),
|
|
child: SizedBox(
|
|
width: _ladoArte,
|
|
height: _ladoArte,
|
|
child: _arte(theme),
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
stationName,
|
|
style: const TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w700,
|
|
height: 1.2,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
if (genero != null)
|
|
Text(
|
|
genero,
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: theme.colorScheme.onSurface.withValues(
|
|
alpha: 0.55,
|
|
),
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _arte(ThemeData theme) {
|
|
if (emisora.favicon != null && emisora.favicon!.isNotEmpty) {
|
|
return CachedNetworkImage(
|
|
imageUrl: emisora.favicon!,
|
|
fit: BoxFit.cover,
|
|
placeholder: (_, __) => _shimmer(theme),
|
|
errorWidget: (_, __, ___) => _iconoFallback(theme),
|
|
);
|
|
}
|
|
return _iconoFallback(theme);
|
|
}
|
|
|
|
Widget _shimmer(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: 20,
|
|
color: theme.colorScheme.onPrimaryContainer,
|
|
),
|
|
);
|
|
}
|