Files
pluriwave/lib/pantallas/pantalla_inicio.dart
FreeTLab 01615f9751 fix(escuchar,reproductor): add the section-heading token and screen 1-3 polish
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.
2026-07-30 14:48:06 +02:00

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,
),
);
}