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
+71
View File
@@ -11,6 +11,7 @@ import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/pantallas/pantalla_inicio.dart';
import 'package:pluriwave/widgets/pluri_root_header.dart';
import 'package:pluriwave/widgets/pluri_station_art_fallback.dart';
import 'package:pluriwave/widgets/visualizador_audio.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -698,6 +699,76 @@ void main() {
);
},
);
testWidgets('issue 6 (feedback-pruebas): the Escuchar hero shows the shared '
'PluriStationArtFallback for a station with no favicon, not a flat '
'coloured square', (tester) async {
_setLargeSurfaceSize(tester);
final favoritos = FakeServicioFavoritos();
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: favoritos,
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
servicioGrabacion: FakeServicioGrabacionRadio(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
await tester.runAsync(estado.inicializar);
final sonando = emisoraDemo(uuid: 'sin-arte', nombre: 'Sin Arte');
await tester.runAsync(() => estado.reproducir(sonando));
await tester.pumpWidget(
_conProviders(estado, _testApp(const PantallaInicio())),
);
await _pumpBounded(tester);
expect(
find.byType(PluriStationArtFallback),
findsWidgets,
reason:
'issue 6: the hero must reach the shared fallback, not its own '
'flat primaryContainer square',
);
});
testWidgets(
'issue 6 (feedback-pruebas): a "Tus emisoras" grid cell shows the shared '
'PluriStationArtFallback for a favourite with no favicon',
(tester) async {
_setLargeSurfaceSize(tester);
final favoritos = FakeServicioFavoritos();
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: favoritos,
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
servicioGrabacion: FakeServicioGrabacionRadio(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
await tester.runAsync(estado.inicializar);
await favoritos.agregar(
emisoraDemo(uuid: 'grid-sin-arte', nombre: 'Grid Sin Arte'),
);
await estado.cargarFavoritos();
await tester.pumpWidget(
_conProviders(estado, _testApp(const PantallaInicio())),
);
await _pumpStableFrame(tester);
expect(
find.byType(PluriStationArtFallback),
findsWidgets,
reason:
'issue 6: the grid cell must reach the shared fallback, not its '
'own flat primaryContainer square',
);
},
);
}
/// Mirrors the app.dart wiring: EstadoRadio owns the domain notifiers and
@@ -12,6 +12,7 @@ import 'package:pluriwave/pantallas/pantalla_reproductor.dart';
import 'package:pluriwave/tema/pluriwave_tokens.dart';
import 'package:pluriwave/widgets/ecualizador_widget.dart';
import 'package:pluriwave/widgets/pluri_glass_surface.dart';
import 'package:pluriwave/widgets/pluri_station_art_fallback.dart';
import 'package:pluriwave/widgets/visualizador_audio.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -704,4 +705,30 @@ void main() {
},
);
});
group('issue 6 (feedback-pruebas): shared station-art fallback', () {
testWidgets(
'a station with no favicon shows the shared PluriStationArtFallback, '
'not a flat coloured square',
(tester) async {
// `emisora` (the file-level fixture) never sets a favicon.
final estado = crearEstado();
addTearDown(estado.dispose);
await montarPantalla(tester, estado);
final arte = find.byKey(const Key('player-hero-art'));
expect(
find.descendant(
of: arte,
matching: find.byType(PluriStationArtFallback),
),
findsOneWidget,
reason:
'issue 6: the full player must reach the shared fallback, '
'not its own flat primaryContainer square',
);
},
);
});
}
+27
View File
@@ -4,6 +4,7 @@ import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/tema/pluriwave_tokens.dart';
import 'package:pluriwave/widgets/fila_emisora_plana.dart';
import 'package:pluriwave/widgets/pluri_station_art_fallback.dart';
import 'package:pluriwave/widgets/tarjeta_emisora.dart';
import 'package:provider/provider.dart';
@@ -77,6 +78,32 @@ void main() {
);
});
testWidgets(
'issue 6 (feedback-pruebas): a station with no favicon shows the shared '
'PluriStationArtFallback, not a flat coloured square',
(tester) async {
final estado = _estado();
addTearDown(estado.dispose);
final emisora = emisoraDemo(uuid: 'sin-arte', nombre: 'Sin Arte');
await tester.pumpWidget(
_host(estado, FilaEmisoraPlana(emisora: emisora, meta: '')),
);
await tester.pump();
expect(
find.descendant(
of: find.byKey(const ValueKey('fila-emisora-plana-arte')),
matching: find.byType(PluriStationArtFallback),
),
findsOneWidget,
reason:
'issue 6: the flat row must reach the shared fallback, not its '
'own flat primaryContainer square',
);
},
);
testWidgets('omits the meta line entirely when empty (no stray gap)', (
tester,
) async {
@@ -5,6 +5,7 @@ import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/tema/pluriwave_theme.dart';
import 'package:pluriwave/tema/pluriwave_tokens.dart';
import 'package:pluriwave/widgets/mini_reproductor.dart';
import 'package:pluriwave/widgets/pluri_station_art_fallback.dart';
import 'package:pluriwave/widgets/visualizador_audio.dart';
import 'package:provider/provider.dart';
@@ -75,6 +76,33 @@ void main() {
},
);
testWidgets(
'issue 6 (feedback-pruebas): a station with no favicon shows the shared '
'PluriStationArtFallback, not a flat coloured square',
(tester) async {
final estado = _estadoConEmisora();
addTearDown(estado.dispose);
await estado.reproducir(
emisoraDemo(uuid: 'sin-arte', nombre: 'Sin Arte'),
);
await tester.pumpWidget(_hostFor(estado));
await tester.pump();
final arte = find.byKey(const ValueKey('mini-reproductor-arte'));
expect(
find.descendant(
of: arte,
matching: find.byType(PluriStationArtFallback),
),
findsOneWidget,
reason:
'issue 6: the mini player must reach the shared fallback, not '
'its own flat primaryContainer square',
);
},
);
testWidgets(
'is opaque -- no BackdropFilter -- unlike the former glass pill',
(tester) async {
@@ -0,0 +1,86 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/widgets/pluri_icon.dart';
import 'package:pluriwave/widgets/pluri_station_art_fallback.dart';
/// Issue 6 (feedback-pruebas): the shared station-art fallback extracted
/// from `TarjetaEmisora`'s original `_fallbackArtFor`. The formula and asset
/// order are pinned exactly — `navegacion_auto_test.dart`'s
/// `indiceArtePara`/`artUriPara` tests mirror the SAME formula independently
/// (protected file, empty diff vs main) and must keep agreeing with this
/// widget without either suite importing the other.
void main() {
Widget host(Widget child) {
return MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(body: SizedBox(width: 60, height: 60, child: child)),
);
}
test('artFor reproduces the canonical aurora/cosmic/pulse/nova order '
'(same formula as navegacion_auto.dart\'s indiceArtePara)', () {
// Single-letter seeds whose codeUnit % 4 covers all 4 indices, mirroring
// navegacion_auto_test.dart's own fixture exactly: 'd'(100)->0 aurora,
// 'a'(97)->1 cosmic, 'b'(98)->2 pulse, 'c'(99)->3 nova.
expect(
PluriStationArtFallback.artFor('d'),
'assets/images/station_art_aurora.png',
);
expect(
PluriStationArtFallback.artFor('a'),
'assets/images/station_art_cosmic.png',
);
expect(
PluriStationArtFallback.artFor('b'),
'assets/images/station_art_pulse.png',
);
expect(
PluriStationArtFallback.artFor('c'),
'assets/images/station_art_nova.png',
);
});
test('artFor is deterministic for a given seed (same station always picks '
'the same art)', () {
const seed = 'uuid-1234-abcd-real-looking';
expect(
PluriStationArtFallback.artFor(seed),
PluriStationArtFallback.artFor(seed),
);
});
testWidgets(
'renders the deterministic station-art asset plus a centred player glyph',
(tester) async {
await tester.pumpWidget(
host(const PluriStationArtFallback(seed: 'd', iconSize: 22)),
);
await tester.pump();
// Scoped by asset-name prefix, not `find.byType(Image).first` — the
// player glyph (`PluriIcon`) ALSO renders via its own `Image.asset`
// internally, so a bare type match would be ambiguous.
final arte = tester
.widgetList<Image>(find.byType(Image))
.firstWhere(
(img) =>
img.image is AssetImage &&
(img.image as AssetImage).assetName.startsWith(
'assets/images/station_art_',
),
);
expect(
(arte.image as AssetImage).assetName,
'assets/images/station_art_aurora.png',
);
final icono = tester.widget<PluriIcon>(find.byType(PluriIcon));
expect(icono.glyph, PluriIconGlyph.player);
expect(icono.variant, PluriIconVariant.activeGlow);
expect(icono.size, 22);
},
);
}
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/widgets/pluri_station_art_fallback.dart';
import 'package:pluriwave/widgets/tarjeta_emisora.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -73,6 +74,24 @@ void main() {
expect(tester.getSize(thumbnailClip), const Size(48, 48));
});
testWidgets(
'issue 6 (feedback-pruebas): a station with no favicon renders via the '
'shared PluriStationArtFallback (extracted from this exact fallback)',
(tester) async {
await tester.pumpWidget(
host(
TarjetaEmisora(
emisora: emisoraDemo(uuid: 'sin-arte', nombre: 'Sin Arte'),
esCompacta: true,
),
),
);
await tester.pump();
expect(find.byType(PluriStationArtFallback), findsOneWidget);
},
);
testWidgets('esCompacta shimmer placeholder is a square block, not a '
'circle', (tester) async {
await tester.pumpWidget(