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.
256 lines
8.2 KiB
Dart
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);
|
|
}
|