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/pluri_station_art_fallback.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 createState() => _PantallaInicioState(); } class _PantallaInicioState extends State { @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>( (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().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( (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( (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(); 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( 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(), ) : _iconoFallback(), ), ), ); } Widget _shimmerCuadrado(ThemeData theme) => shimmer.Shimmer.fromColors( baseColor: theme.colorScheme.surfaceContainerHighest, highlightColor: theme.colorScheme.surface, child: Container(color: theme.colorScheme.surfaceContainerHighest), ); // Issue 6 (feedback-pruebas): was a flat `primaryContainer` square with a // bare `radio_rounded` icon — now the same shared fallback every other // surface uses. Widget _iconoFallback() => PluriStationArtFallback(seed: emisora.uuid, iconSize: 36); } /// 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(); final esFavorito = context.select( (e) => e.listaFavoritos.any((x) => x.uuid == emisora.uuid), ); final eqActivo = context.select((e) => e.activo); final timerActivo = context.select( (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().cambiarActivo(!eqActivo), ), StreamBuilder( 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( 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 _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 = [ 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(), ); } return _iconoFallback(); } Widget _shimmer(ThemeData theme) => shimmer.Shimmer.fromColors( baseColor: theme.colorScheme.surfaceContainerHighest, highlightColor: theme.colorScheme.surface, child: Container(color: theme.colorScheme.surfaceContainerHighest), ); // Issue 6 (feedback-pruebas): was a flat `primaryContainer` square with a // bare `radio_rounded` icon — now the same shared fallback every other // surface uses. Widget _iconoFallback() => PluriStationArtFallback(seed: emisora.uuid, iconSize: 20); }