fix(favoritos,grabaciones): replace glass cards with flat rows
Item 23 / audit 4.3, 12.4 (t4:226-232, 616-619): Favoritos and Grabaciones rows were full glass cards / ListTiles with two stacked buttons and no artwork slot. Replace with flat, background-less rows via a new shared FilaEmisoraPlana widget (square art, name+meta, a circular play affordance) plus a bespoke Grabaciones row (44x12 placeholder art -- recordings carry no per-station favicon, so this is a themed fallback, not invented artwork). Favoritos keeps "Move to list" / "Remove from favorites" behind an overflow menu (same underlying methods, unchanged) instead of two always-visible buttons, since dropping either would be a functional regression the prototype's own row doesn't have to solve for. Also 12.1 (t4:610): the Grabaciones header action is folder_open, not a generic gear.
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
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';
|
||||
|
||||
/// 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(theme),
|
||||
);
|
||||
}
|
||||
return _iconoFallback(theme);
|
||||
}
|
||||
|
||||
Widget _shimmer(ThemeData theme) => shimmer.Shimmer.fromColors(
|
||||
baseColor: theme.colorScheme.surfaceContainerHighest,
|
||||
highlightColor: theme.colorScheme.surface,
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user