fix(widgets): share station-art fallback across every surface

TarjetaEmisora had the only good fallback for a station with no artwork --
a deterministic pick from 4 bundled illustrations with a gradient/glyph
last resort. FilaEmisoraPlana's flat rows, the Escuchar hero, the "Tus
emisoras" grid cell, the mini player and the full player each had their
own, separate, flat primaryContainer square instead.

Extract the good fallback into PluriStationArtFallback and use it from
every one of those call sites. The selection formula (asset order,
codeUnits-sum modulo) is preserved exactly, since navegacion_auto.dart
mirrors the same formula independently for Android Auto's own drawable
rotation.
This commit is contained in:
2026-07-30 18:54:31 +02:00
parent 4be2156e58
commit d1a911e587
12 changed files with 395 additions and 94 deletions
+8 -10
View File
@@ -8,6 +8,7 @@ import '../l10n/display_names.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/emisora.dart';
import '../tema/pluriwave_tokens.dart';
import 'pluri_station_art_fallback.dart';
/// Item 23 / audit 4.3 + 6.5 (t4:226-232, 302-306): a flat, background-less
/// station row — square thumbnail, name, meta line, and a caller-supplied
@@ -234,10 +235,10 @@ class _ArteFilaEmisora extends StatelessWidget {
imageUrl: emisora.favicon!,
fit: BoxFit.cover,
placeholder: (_, __) => _shimmer(theme),
errorWidget: (_, __, ___) => _iconoFallback(theme),
errorWidget: (_, __, ___) => _iconoFallback(),
);
}
return _iconoFallback(theme);
return _iconoFallback();
}
Widget _shimmer(ThemeData theme) => shimmer.Shimmer.fromColors(
@@ -246,12 +247,9 @@ class _ArteFilaEmisora extends StatelessWidget {
child: Container(color: theme.colorScheme.surfaceContainerHighest),
);
Widget _iconoFallback(ThemeData theme) => Container(
color: theme.colorScheme.primaryContainer,
child: Icon(
Icons.radio_rounded,
size: 22,
color: theme.colorScheme.onPrimaryContainer,
),
);
// 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: 22);
}
+8 -10
View File
@@ -10,6 +10,7 @@ import '../modelos/emisora.dart';
import '../pantallas/pantalla_reproductor.dart';
import '../servicios/servicio_audio.dart';
import '../tema/pluriwave_theme.dart';
import 'pluri_station_art_fallback.dart';
/// Barra inferior persistente con controles básicos de reproducción.
/// Toca la barra para abrir PantallaReproductor completa.
@@ -318,9 +319,9 @@ class _ArteMiniReproductor extends StatelessWidget {
imageUrl: emisora.favicon!,
fit: BoxFit.cover,
placeholder: (_, __) => _shimmer(theme),
errorWidget: (_, __, ___) => _iconoFallback(theme),
errorWidget: (_, __, ___) => _iconoFallback(),
)
: _iconoFallback(theme),
: _iconoFallback(),
),
);
}
@@ -331,12 +332,9 @@ class _ArteMiniReproductor extends StatelessWidget {
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,
),
);
// 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);
}
@@ -0,0 +1,92 @@
import 'package:flutter/material.dart';
import '../l10n/gen/app_localizations.dart';
import '../tema/pluriwave_theme.dart';
import 'pluri_icon.dart';
/// Shared station-art fallback (feedback-pruebas issue 6).
///
/// Before this widget existed, `TarjetaEmisora` had the only good fallback —
/// a deterministic pick from 4 bundled illustrations (`_fallbackArtFor`),
/// with a gradient + player glyph as a last resort if the asset itself ever
/// fails to decode. Every OTHER surface that can render a station without
/// artwork (`FilaEmisoraPlana`'s flat rows, the Escuchar hero, the "Tus
/// emisoras" grid cell, the mini player, the full player) had its own,
/// separate, much poorer copy: a flat `primaryContainer`-coloured square
/// with a bare `radio_rounded` icon. This widget is the ONE shared
/// implementation every one of those call sites now uses instead.
///
/// The selection formula is pinned EXACTLY as `TarjetaEmisora` originally
/// had it (`seed.codeUnits.fold<int>(0, (a, b) => a + b) % 4`, asset order
/// aurora/cosmic/pulse/nova) — `lib/servicios/navegacion_auto.dart` (a
/// protected, empty-diff file) independently mirrors this SAME formula for
/// Android Auto's own drawable-resource rotation
/// (`test/servicios/navegacion_auto_test.dart`'s `indiceArtePara`/
/// `artUriPara` tests assert it inline, not by importing this widget) —
/// changing the order or the modulo here would silently desync the two
/// without either test suite noticing until an actual device compared them
/// side by side.
class PluriStationArtFallback extends StatelessWidget {
const PluriStationArtFallback({
super.key,
required this.seed,
this.iconSize = 22,
});
/// Typically the station's `uuid` — the seed that deterministically picks
/// one of the 4 bundled arts below.
final String seed;
/// Size of the centred player glyph drawn over the art.
final double iconSize;
static const _arts = [
'assets/images/station_art_aurora.png',
'assets/images/station_art_cosmic.png',
'assets/images/station_art_pulse.png',
'assets/images/station_art_nova.png',
];
/// Exposed so other call sites (and tests) can assert which asset a given
/// seed resolves to without needing to render the widget.
static String artFor(String seed) {
final index = seed.codeUnits.fold<int>(0, (a, b) => a + b) % _arts.length;
return _arts[index];
}
@override
Widget build(BuildContext context) {
return Stack(
fit: StackFit.expand,
children: [
Image.asset(
artFor(seed),
fit: BoxFit.cover,
errorBuilder:
(_, __, ___) => DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
context.pluriTokens.deepViolet,
context.pluriTokens.electricMagenta.withValues(
alpha: 0.8,
),
],
),
),
),
),
Center(
child: PluriIcon(
glyph: PluriIconGlyph.player,
variant: PluriIconVariant.activeGlow,
size: iconSize,
semanticLabel: AppLocalizations.of(context).stationIconLabel,
),
),
],
);
}
}
+6 -44
View File
@@ -10,6 +10,7 @@ import '../modelos/emisora.dart';
import '../tema/pluriwave_theme.dart';
import 'pluri_glass_surface.dart';
import 'pluri_icon.dart';
import 'pluri_station_art_fallback.dart';
/// Tarjeta compacta para mostrar una emisora en listas y grids.
/// Incluye botón de favorito visible en ambos modos.
@@ -302,51 +303,12 @@ class _TarjetaEmisoraState extends State<TarjetaEmisora> {
);
}
// Issue 6 (feedback-pruebas): extracted into the shared
// `PluriStationArtFallback` so every surface that can render a station
// without artwork shows the SAME fallback — see that widget's own doc
// comment for why the selection formula must never drift.
Widget _iconoFallback(double size) {
final art = _fallbackArtFor(widget.emisora.uuid);
return Stack(
fit: StackFit.expand,
children: [
Image.asset(
art,
fit: BoxFit.cover,
errorBuilder:
(_, __, ___) => DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
context.pluriTokens.deepViolet,
context.pluriTokens.electricMagenta.withValues(
alpha: 0.8,
),
],
),
),
),
),
Center(
child: PluriIcon(
glyph: PluriIconGlyph.player,
variant: PluriIconVariant.activeGlow,
size: size,
semanticLabel: AppLocalizations.of(context).stationIconLabel,
),
),
],
);
}
String _fallbackArtFor(String seed) {
const arts = [
'assets/images/station_art_aurora.png',
'assets/images/station_art_cosmic.png',
'assets/images/station_art_pulse.png',
'assets/images/station_art_nova.png',
];
final index = seed.codeUnits.fold<int>(0, (a, b) => a + b) % arts.length;
return arts[index];
return PluriStationArtFallback(seed: widget.emisora.uuid, iconSize: size);
}
}