Files
pluriwave/lib/widgets/fila_emisora_plana.dart
FreeTLab d1a911e587 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.
2026-07-30 18:54:31 +02:00

256 lines
8.2 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_radio.dart';
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
/// set of trailing actions. Shared by Favoritos and search results, which
/// the prototype draws identically apart from their trailing actions.
/// Grabaciones (12.4) has its own row: a different domain model
/// (recordings, not stations) with different trailing actions.
class FilaEmisoraPlana extends StatelessWidget {
const FilaEmisoraPlana({
super.key,
required this.emisora,
required this.meta,
this.leading,
this.trailing = const [],
this.onTap,
});
final Emisora emisora;
/// Pre-joined meta line (e.g. "genre - country - kbps"). Omitted from
/// layout entirely when empty, rather than reserving blank space.
final String meta;
/// e.g. a drag handle (Favoritos only — search results have none).
final Widget? leading;
/// e.g. a favourite toggle, a circular play button, an overflow menu.
final List<Widget> trailing;
final VoidCallback? onTap;
/// t4:226, t4:302: 48x48, radius 12.
static const double lado = 48;
static const double radio = 12;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final stationName = localizedStationName(l10n, emisora.nombre);
return Padding(
padding: const EdgeInsets.all(8),
child: Row(
children: [
if (leading != null) ...[leading!, const SizedBox(width: 4)],
ClipRRect(
key: const ValueKey('fila-emisora-plana-arte'),
borderRadius: BorderRadius.circular(radio),
child: SizedBox(
width: lado,
height: lado,
child: _ArteFilaEmisora(emisora: emisora),
),
),
const SizedBox(width: 12),
Expanded(
child: Material(
type: MaterialType.transparency,
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// t4:229, t4:303: 15px/w700/lh1.25.
Text(
stationName,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
height: 1.25,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (meta.isNotEmpty) ...[
const SizedBox(height: 2),
// t4:229, t4:303: 12px/rgba(242,247,250,.58).
Text(
meta,
style: TextStyle(
fontSize: 12,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.58),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
),
),
),
...trailing,
],
),
);
}
}
/// t4:230, t4:305: a 44x44 circular affordance, `rgba(33,212,217,.14)`
/// background, `play_arrow` at 22px brand teal.
class BotonReproducirCircular extends StatelessWidget {
const BotonReproducirCircular({
super.key,
required this.onPressed,
this.tooltip,
});
final VoidCallback? onPressed;
final String? tooltip;
@override
Widget build(BuildContext context) {
return Material(
key: const ValueKey('boton-reproducir-circular'),
color: PluriWaveTokens.brand.withValues(alpha: 0.14),
shape: const CircleBorder(),
child: IconButton(
tooltip: tooltip,
icon: const Icon(
Icons.play_arrow_rounded,
size: 22,
color: PluriWaveTokens.brand,
),
onPressed: onPressed,
constraints: const BoxConstraints.tightFor(width: 44, height: 44),
),
);
}
}
/// t4:304: a 44x44 favourite toggle, 22px icon at 55% opacity. Mirrors
/// `TarjetaEmisora`'s own toggle+snackbar behaviour (duplicated rather than
/// extracted from it — `tarjeta_emisora.dart`'s exact widget tree is pinned
/// by several pre-existing tests this change must not disturb).
class BotonFavoritoEmisora extends StatefulWidget {
const BotonFavoritoEmisora({super.key, required this.emisora});
final Emisora emisora;
@override
State<BotonFavoritoEmisora> createState() => _BotonFavoritoEmisoraState();
}
class _BotonFavoritoEmisoraState extends State<BotonFavoritoEmisora> {
bool _toggling = false;
Future<void> _toggle() async {
if (_toggling) return;
setState(() => _toggling = true);
final estado = context.read<EstadoRadio>();
final esFav = await estado.toggleFavorito(widget.emisora);
if (mounted) setState(() => _toggling = false);
if (mounted) {
final l10n = AppLocalizations.of(context);
final stationName = localizedStationName(l10n, widget.emisora.nombre);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
esFav
? l10n.favoritesAddedMessage(stationName)
: l10n.favoritesRemovedMessage(stationName),
),
duration: const Duration(seconds: 2),
),
);
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final esFavorito = context.select<EstadoRadio, bool>(
(estado) =>
estado.listaFavoritos.any((e) => e.uuid == widget.emisora.uuid),
);
return Semantics(
container: true,
button: true,
toggled: esFavorito,
label:
esFavorito ? l10n.favoritesRemoveTooltip : l10n.favoritesAddTooltip,
child: Material(
key: const ValueKey('boton-favorito-emisora'),
color: Colors.transparent,
child: InkWell(
customBorder: const CircleBorder(),
onTap: _toggling ? null : _toggle,
child: SizedBox(
width: 44,
height: 44,
child: Icon(
esFavorito
? Icons.favorite_rounded
: Icons.favorite_outline_rounded,
size: 22,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.55),
),
),
),
),
);
}
}
class _ArteFilaEmisora extends StatelessWidget {
const _ArteFilaEmisora({required this.emisora});
final Emisora emisora;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
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: 22);
}