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:
2026-07-30 11:46:55 +02:00
parent 4537497e83
commit 955682271c
6 changed files with 727 additions and 92 deletions
+59 -39
View File
@@ -6,13 +6,13 @@ import '../l10n/display_names.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/emisora.dart';
import '../modelos/grupo_favoritos.dart';
import '../widgets/fila_emisora_plana.dart';
import '../widgets/pluri_icon.dart';
import '../widgets/pluri_layout.dart';
import '../widgets/pluri_premium_widgets.dart';
import '../widgets/pluri_push_scaffold.dart';
import '../widgets/pluri_root_header.dart';
import '../widgets/pluri_sleep_timer_sheet.dart';
import 'package:pluriwave/widgets/tarjeta_emisora.dart';
import 'ajustes/pantalla_ajustes_emisoras_personalizadas.dart';
import 'ajustes/pantalla_ajustes_grupos_favoritos.dart';
@@ -399,45 +399,65 @@ class _FilaFavorito extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
ReorderableDragStartListener(
index: index,
child: const Padding(
padding: EdgeInsets.only(right: 4),
child: Icon(Icons.drag_handle_rounded),
),
),
Expanded(
child: TarjetaEmisora(
key: Key(emisora.uuid),
emisora: emisora,
esCompacta: true,
onTap: () => reproducirMinimizado(context, emisora),
),
),
const SizedBox(width: 6),
Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton.filledTonal(
tooltip: l10n.favoriteGroupsAssignSubtitle(
_nombreVisible(l10n, grupoActual),
),
icon: const Icon(Icons.drive_file_move_rounded),
onPressed: () => _asignar(context),
),
IconButton.filledTonal(
tooltip: l10n.favoritesRemoveTooltip,
icon: const Icon(Icons.delete_outline_rounded),
onPressed: () => _eliminar(context),
),
],
),
],
final meta = [
emisora.pais,
emisora.idioma,
].where((s) => s != null && s.isNotEmpty).join(' · ');
// Item 23 / audit 4.3 (t4:226-232): a flat, background-less row --
// drag handle, square art, name+meta, and a circular play affordance --
// replacing the full glass TarjetaEmisora card and its two stacked
// filledTonal buttons. "Move to list"/"Remove from favorites" keep their
// EXACT prior logic (`_asignar`/`_eliminar`, untouched), now reachable
// from an overflow menu instead of two always-visible buttons -- the
// prototype's row has no such menu, but dropping either capability
// entirely would be a functional regression, not a fidelity fix.
return FilaEmisoraPlana(
key: Key(emisora.uuid),
emisora: emisora,
meta: meta,
onTap: () => reproducirMinimizado(context, emisora),
leading: ReorderableDragStartListener(
index: index,
child: Icon(
// t4:227: drag_indicator, not drag_handle, at 22px/28%.
Icons.drag_indicator_rounded,
size: 22,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.28),
),
),
trailing: [
BotonReproducirCircular(
onPressed: () => reproducirMinimizado(context, emisora),
),
PopupMenuButton<String>(
icon: Icon(
Icons.more_vert_rounded,
size: 20,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.45),
),
constraints: const BoxConstraints.tightFor(width: 38, height: 42),
onSelected: (accion) {
if (accion == 'assign') _asignar(context);
if (accion == 'remove') _eliminar(context);
},
itemBuilder:
(context) => [
PopupMenuItem(
value: 'assign',
child: Text(l10n.favoriteGroupsAssign),
),
PopupMenuItem(
value: 'remove',
child: Text(l10n.favoritesRemoveTooltip),
),
],
),
],
);
}
}
+130 -52
View File
@@ -8,6 +8,7 @@ import 'package:share_plus/share_plus.dart' show Share, XFile;
import '../estado/estado_grabacion.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/archivo_grabacion.dart';
import '../tema/pluriwave_tokens.dart';
import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_icon.dart';
import '../widgets/pluri_layout.dart';
@@ -231,7 +232,9 @@ class _PantallaGrabacionesState extends State<PantallaGrabaciones> {
title: l10n.recordingsLibraryTitle,
actions: [
IconButton(
icon: const Icon(Icons.settings_outlined),
// Audit 12.1 (t4:610): the header action is `folder_open` at
// 22px, not a generic gear.
icon: const Icon(Icons.folder_open_rounded, size: 22),
tooltip: l10n.recordingsLibrarySettingsTooltip,
onPressed:
() => PluriPushScaffold.push(
@@ -257,20 +260,27 @@ class _PantallaGrabacionesState extends State<PantallaGrabaciones> {
subtitle: l10n.recordingsLibraryEmptySubtitle,
)
else
for (final archivo in archivos)
_FilaGrabacion(
archivo: archivo,
reproduciendo:
_reproductor.rutaActual == archivo.ruta &&
_reproductor.reproduciendo,
duracion: _duracionPara(archivo.ruta),
formatearDuracion: _formatearDuracion,
formatearFecha: _formatearFecha,
formatearBytes: _formatearBytes,
onAlternarReproduccion:
() => _alternarReproduccion(archivo.ruta),
onAccionMenu: (accion) => _manejarAccion(accion, archivo),
),
// t4:618: `gap:2px` between rows.
PluriPanelColumn(
gap: 2,
children: [
for (final archivo in archivos)
_FilaGrabacion(
archivo: archivo,
reproduciendo:
_reproductor.rutaActual == archivo.ruta &&
_reproductor.reproduciendo,
duracion: _duracionPara(archivo.ruta),
formatearDuracion: _formatearDuracion,
formatearFecha: _formatearFecha,
formatearBytes: _formatearBytes,
onAlternarReproduccion:
() => _alternarReproduccion(archivo.ruta),
onAccionMenu:
(accion) => _manejarAccion(accion, archivo),
),
],
),
],
);
},
@@ -337,48 +347,116 @@ class _FilaGrabacion extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return PluriGlassSurface(
child: ListTile(
contentPadding: EdgeInsets.zero,
leading: IconButton(
icon: Icon(
reproduciendo
? Icons.pause_circle_filled_rounded
: Icons.play_circle_fill_rounded,
// Item 23 / audit 12.4 (t4:616-619): a flat, background-less row --
// 44x44/radius-12 art placeholder (recordings carry no per-station
// favicon, so this is a themed fallback square, not invented artwork),
// name, meta line, a 24px play/pause affordance, and the SAME "-"
// menu (Rename/Share/Delete) as before, just restyled.
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 10),
child: Row(
children: [
ClipRRect(
key: const ValueKey('fila-grabacion-arte'),
borderRadius: BorderRadius.circular(12),
child: Container(
width: 44,
height: 44,
color: theme.colorScheme.primaryContainer,
child: Icon(
Icons.radio_rounded,
size: 22,
color: theme.colorScheme.onPrimaryContainer,
),
),
),
onPressed: onAlternarReproduccion,
),
title: Text(archivo.nombre),
subtitle: FutureBuilder<Duration?>(
future: duracion,
builder: (context, snap) {
return Text(
'${formatearFecha(archivo.fecha)} · '
'${formatearDuracion(snap.data)} · '
'${formatearBytes(archivo.tamanoBytes)}',
);
},
),
trailing: PopupMenuButton<String>(
icon: const Icon(Icons.more_vert_rounded),
onSelected: onAccionMenu,
itemBuilder:
(context) => [
PopupMenuItem(
value: 'rename',
child: Text(l10n.recordingActionRename),
const SizedBox(width: 12),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// t4:619: 14.5px/w700.
Text(
archivo.nombre,
style: const TextStyle(
fontSize: 14.5,
fontWeight: FontWeight.w700,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
PopupMenuItem(
value: 'share',
child: Text(l10n.recordingActionShare),
),
PopupMenuItem(
value: 'delete',
child: Text(l10n.recordingActionDelete),
// t4:619: 12px/rgba(242,247,250,.55).
FutureBuilder<Duration?>(
future: duracion,
builder: (context, snap) {
return Text(
'${formatearFecha(archivo.fecha)} · '
'${formatearDuracion(snap.data)} · '
'${formatearBytes(archivo.tamanoBytes)}',
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface.withValues(
alpha: 0.55,
),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
},
),
],
),
),
),
// t4:619: play_circle 24px brand teal in a 42x42 target.
SizedBox(
width: 42,
height: 42,
child: IconButton(
padding: EdgeInsets.zero,
tooltip: reproduciendo ? l10n.pauseAction : l10n.playAction,
icon: Icon(
reproduciendo
? Icons.pause_circle_filled_rounded
: Icons.play_circle_fill_rounded,
size: 24,
color: PluriWaveTokens.brand,
),
onPressed: onAlternarReproduccion,
),
),
// t4:619: more_vert 20px/45% in a 38x42 target.
SizedBox(
width: 38,
height: 42,
child: PopupMenuButton<String>(
padding: EdgeInsets.zero,
icon: Icon(
Icons.more_vert_rounded,
size: 20,
color: theme.colorScheme.onSurface.withValues(alpha: 0.45),
),
onSelected: onAccionMenu,
itemBuilder:
(context) => [
PopupMenuItem(
value: 'rename',
child: Text(l10n.recordingActionRename),
),
PopupMenuItem(
value: 'share',
child: Text(l10n.recordingActionShare),
),
PopupMenuItem(
value: 'delete',
child: Text(l10n.recordingActionDelete),
),
],
),
),
],
),
);
}
+257
View File
@@ -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,
),
);
}