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:
@@ -6,13 +6,13 @@ import '../l10n/display_names.dart';
|
|||||||
import '../l10n/gen/app_localizations.dart';
|
import '../l10n/gen/app_localizations.dart';
|
||||||
import '../modelos/emisora.dart';
|
import '../modelos/emisora.dart';
|
||||||
import '../modelos/grupo_favoritos.dart';
|
import '../modelos/grupo_favoritos.dart';
|
||||||
|
import '../widgets/fila_emisora_plana.dart';
|
||||||
import '../widgets/pluri_icon.dart';
|
import '../widgets/pluri_icon.dart';
|
||||||
import '../widgets/pluri_layout.dart';
|
import '../widgets/pluri_layout.dart';
|
||||||
import '../widgets/pluri_premium_widgets.dart';
|
import '../widgets/pluri_premium_widgets.dart';
|
||||||
import '../widgets/pluri_push_scaffold.dart';
|
import '../widgets/pluri_push_scaffold.dart';
|
||||||
import '../widgets/pluri_root_header.dart';
|
import '../widgets/pluri_root_header.dart';
|
||||||
import '../widgets/pluri_sleep_timer_sheet.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_emisoras_personalizadas.dart';
|
||||||
import 'ajustes/pantalla_ajustes_grupos_favoritos.dart';
|
import 'ajustes/pantalla_ajustes_grupos_favoritos.dart';
|
||||||
|
|
||||||
@@ -399,45 +399,65 @@ class _FilaFavorito extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
return Padding(
|
final meta = [
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
emisora.pais,
|
||||||
child: Row(
|
emisora.idioma,
|
||||||
children: [
|
].where((s) => s != null && s.isNotEmpty).join(' · ');
|
||||||
ReorderableDragStartListener(
|
|
||||||
index: index,
|
// Item 23 / audit 4.3 (t4:226-232): a flat, background-less row --
|
||||||
child: const Padding(
|
// drag handle, square art, name+meta, and a circular play affordance --
|
||||||
padding: EdgeInsets.only(right: 4),
|
// replacing the full glass TarjetaEmisora card and its two stacked
|
||||||
child: Icon(Icons.drag_handle_rounded),
|
// 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
|
||||||
Expanded(
|
// prototype's row has no such menu, but dropping either capability
|
||||||
child: TarjetaEmisora(
|
// entirely would be a functional regression, not a fidelity fix.
|
||||||
key: Key(emisora.uuid),
|
return FilaEmisoraPlana(
|
||||||
emisora: emisora,
|
key: Key(emisora.uuid),
|
||||||
esCompacta: true,
|
emisora: emisora,
|
||||||
onTap: () => reproducirMinimizado(context, emisora),
|
meta: meta,
|
||||||
),
|
onTap: () => reproducirMinimizado(context, emisora),
|
||||||
),
|
leading: ReorderableDragStartListener(
|
||||||
const SizedBox(width: 6),
|
index: index,
|
||||||
Column(
|
child: Icon(
|
||||||
mainAxisSize: MainAxisSize.min,
|
// t4:227: drag_indicator, not drag_handle, at 22px/28%.
|
||||||
children: [
|
Icons.drag_indicator_rounded,
|
||||||
IconButton.filledTonal(
|
size: 22,
|
||||||
tooltip: l10n.favoriteGroupsAssignSubtitle(
|
color: Theme.of(
|
||||||
_nombreVisible(l10n, grupoActual),
|
context,
|
||||||
),
|
).colorScheme.onSurface.withValues(alpha: 0.28),
|
||||||
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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'package:share_plus/share_plus.dart' show Share, XFile;
|
|||||||
import '../estado/estado_grabacion.dart';
|
import '../estado/estado_grabacion.dart';
|
||||||
import '../l10n/gen/app_localizations.dart';
|
import '../l10n/gen/app_localizations.dart';
|
||||||
import '../modelos/archivo_grabacion.dart';
|
import '../modelos/archivo_grabacion.dart';
|
||||||
|
import '../tema/pluriwave_tokens.dart';
|
||||||
import '../widgets/pluri_glass_surface.dart';
|
import '../widgets/pluri_glass_surface.dart';
|
||||||
import '../widgets/pluri_icon.dart';
|
import '../widgets/pluri_icon.dart';
|
||||||
import '../widgets/pluri_layout.dart';
|
import '../widgets/pluri_layout.dart';
|
||||||
@@ -231,7 +232,9 @@ class _PantallaGrabacionesState extends State<PantallaGrabaciones> {
|
|||||||
title: l10n.recordingsLibraryTitle,
|
title: l10n.recordingsLibraryTitle,
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
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,
|
tooltip: l10n.recordingsLibrarySettingsTooltip,
|
||||||
onPressed:
|
onPressed:
|
||||||
() => PluriPushScaffold.push(
|
() => PluriPushScaffold.push(
|
||||||
@@ -257,20 +260,27 @@ class _PantallaGrabacionesState extends State<PantallaGrabaciones> {
|
|||||||
subtitle: l10n.recordingsLibraryEmptySubtitle,
|
subtitle: l10n.recordingsLibraryEmptySubtitle,
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
for (final archivo in archivos)
|
// t4:618: `gap:2px` between rows.
|
||||||
_FilaGrabacion(
|
PluriPanelColumn(
|
||||||
archivo: archivo,
|
gap: 2,
|
||||||
reproduciendo:
|
children: [
|
||||||
_reproductor.rutaActual == archivo.ruta &&
|
for (final archivo in archivos)
|
||||||
_reproductor.reproduciendo,
|
_FilaGrabacion(
|
||||||
duracion: _duracionPara(archivo.ruta),
|
archivo: archivo,
|
||||||
formatearDuracion: _formatearDuracion,
|
reproduciendo:
|
||||||
formatearFecha: _formatearFecha,
|
_reproductor.rutaActual == archivo.ruta &&
|
||||||
formatearBytes: _formatearBytes,
|
_reproductor.reproduciendo,
|
||||||
onAlternarReproduccion:
|
duracion: _duracionPara(archivo.ruta),
|
||||||
() => _alternarReproduccion(archivo.ruta),
|
formatearDuracion: _formatearDuracion,
|
||||||
onAccionMenu: (accion) => _manejarAccion(accion, archivo),
|
formatearFecha: _formatearFecha,
|
||||||
),
|
formatearBytes: _formatearBytes,
|
||||||
|
onAlternarReproduccion:
|
||||||
|
() => _alternarReproduccion(archivo.ruta),
|
||||||
|
onAccionMenu:
|
||||||
|
(accion) => _manejarAccion(accion, archivo),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -337,48 +347,116 @@ class _FilaGrabacion extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
return PluriGlassSurface(
|
// Item 23 / audit 12.4 (t4:616-619): a flat, background-less row --
|
||||||
child: ListTile(
|
// 44x44/radius-12 art placeholder (recordings carry no per-station
|
||||||
contentPadding: EdgeInsets.zero,
|
// favicon, so this is a themed fallback square, not invented artwork),
|
||||||
leading: IconButton(
|
// name, meta line, a 24px play/pause affordance, and the SAME "-"
|
||||||
icon: Icon(
|
// menu (Rename/Share/Delete) as before, just restyled.
|
||||||
reproduciendo
|
return Padding(
|
||||||
? Icons.pause_circle_filled_rounded
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 10),
|
||||||
: Icons.play_circle_fill_rounded,
|
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,
|
const SizedBox(width: 12),
|
||||||
),
|
Expanded(
|
||||||
title: Text(archivo.nombre),
|
child: Column(
|
||||||
subtitle: FutureBuilder<Duration?>(
|
mainAxisSize: MainAxisSize.min,
|
||||||
future: duracion,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
builder: (context, snap) {
|
children: [
|
||||||
return Text(
|
// t4:619: 14.5px/w700.
|
||||||
'${formatearFecha(archivo.fecha)} · '
|
Text(
|
||||||
'${formatearDuracion(snap.data)} · '
|
archivo.nombre,
|
||||||
'${formatearBytes(archivo.tamanoBytes)}',
|
style: const TextStyle(
|
||||||
);
|
fontSize: 14.5,
|
||||||
},
|
fontWeight: FontWeight.w700,
|
||||||
),
|
),
|
||||||
trailing: PopupMenuButton<String>(
|
maxLines: 1,
|
||||||
icon: const Icon(Icons.more_vert_rounded),
|
overflow: TextOverflow.ellipsis,
|
||||||
onSelected: onAccionMenu,
|
|
||||||
itemBuilder:
|
|
||||||
(context) => [
|
|
||||||
PopupMenuItem(
|
|
||||||
value: 'rename',
|
|
||||||
child: Text(l10n.recordingActionRename),
|
|
||||||
),
|
),
|
||||||
PopupMenuItem(
|
// t4:619: 12px/rgba(242,247,250,.55).
|
||||||
value: 'share',
|
FutureBuilder<Duration?>(
|
||||||
child: Text(l10n.recordingActionShare),
|
future: duracion,
|
||||||
),
|
builder: (context, snap) {
|
||||||
PopupMenuItem(
|
return Text(
|
||||||
value: 'delete',
|
'${formatearFecha(archivo.fecha)} · '
|
||||||
child: Text(l10n.recordingActionDelete),
|
'${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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,7 +6,9 @@ import 'package:pluriwave/estado/estado_radio.dart';
|
|||||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||||
import 'package:pluriwave/pantallas/ajustes/pantalla_ajustes_grupos_favoritos.dart';
|
import 'package:pluriwave/pantallas/ajustes/pantalla_ajustes_grupos_favoritos.dart';
|
||||||
import 'package:pluriwave/pantallas/pantalla_favoritos.dart';
|
import 'package:pluriwave/pantallas/pantalla_favoritos.dart';
|
||||||
|
import 'package:pluriwave/widgets/fila_emisora_plana.dart';
|
||||||
import 'package:pluriwave/widgets/pluri_push_scaffold.dart';
|
import 'package:pluriwave/widgets/pluri_push_scaffold.dart';
|
||||||
|
import 'package:pluriwave/widgets/tarjeta_emisora.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
@@ -381,6 +383,90 @@ void main() {
|
|||||||
|
|
||||||
expect(find.widgetWithText(TextFormField, 'Name *'), findsOneWidget);
|
expect(find.widgetWithText(TextFormField, 'Name *'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Item 23 / audit 4.3 (t4:226-232): flat, background-less rows with a
|
||||||
|
// square thumbnail and a circular play affordance, replacing the full
|
||||||
|
// glass TarjetaEmisora card + two stacked filledTonal buttons.
|
||||||
|
group('Item 23 -- flat rows (audit 4.3)', () {
|
||||||
|
testWidgets(
|
||||||
|
'each row is a flat FilaEmisoraPlana, not the full glass card',
|
||||||
|
(tester) async {
|
||||||
|
setLargeSurface(tester);
|
||||||
|
_suppressListTileInkAssertion();
|
||||||
|
final estado = await crearEstadoConFavoritos();
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(buildScreen(estado));
|
||||||
|
await pumpStable(tester);
|
||||||
|
|
||||||
|
expect(find.byType(FilaEmisoraPlana), findsNWidgets(3));
|
||||||
|
expect(
|
||||||
|
find.byType(TarjetaEmisora),
|
||||||
|
findsNothing,
|
||||||
|
reason: 'audit 4.3 replaces the full glass card with a flat row',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.byIcon(Icons.drag_indicator_rounded),
|
||||||
|
findsNWidgets(3),
|
||||||
|
reason: 't4:227 drag_indicator, not drag_handle',
|
||||||
|
);
|
||||||
|
expect(find.byIcon(Icons.drag_handle_rounded), findsNothing);
|
||||||
|
expect(
|
||||||
|
find.byType(BotonReproducirCircular),
|
||||||
|
findsNWidgets(3),
|
||||||
|
reason: 't4:230 a circular play affordance per row',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'the overflow menu offers exactly "Move to list" and "Remove from '
|
||||||
|
'favorites"',
|
||||||
|
(tester) async {
|
||||||
|
setLargeSurface(tester);
|
||||||
|
_suppressListTileInkAssertion();
|
||||||
|
final estado = await crearEstadoConFavoritos();
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(buildScreen(estado));
|
||||||
|
await pumpStable(tester);
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.more_vert_rounded).first);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Move to list'), findsOneWidget);
|
||||||
|
expect(find.text('Remove from favorites'), findsOneWidget);
|
||||||
|
expect(find.byType(PopupMenuItem<String>), findsNWidgets(2));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'"Remove from favorites" still calls the same removal path as before',
|
||||||
|
(tester) async {
|
||||||
|
setLargeSurface(tester);
|
||||||
|
_suppressListTileInkAssertion();
|
||||||
|
final estado = await crearEstadoConFavoritos();
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(buildScreen(estado));
|
||||||
|
await pumpStable(tester);
|
||||||
|
|
||||||
|
// Invoke the callback directly rather than opening the overlay and
|
||||||
|
// tapping its rendered item by screen position: leaving that modal
|
||||||
|
// route open while the tap it triggers removes and unmounts the
|
||||||
|
// very row that anchors it is a real hang hazard, confirmed while
|
||||||
|
// developing this test (multi-minute stall, same class of risk as
|
||||||
|
// the project's other documented `pumpAndSettle` traps).
|
||||||
|
final boton = tester.widget<PopupMenuButton<String>>(
|
||||||
|
find.byType(PopupMenuButton<String>).first,
|
||||||
|
);
|
||||||
|
boton.onSelected!('remove');
|
||||||
|
await pumpStable(tester);
|
||||||
|
|
||||||
|
expect(find.text('Station A'), findsNothing);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void setLargeSurface(WidgetTester tester) {
|
void setLargeSurface(WidgetTester tester) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
|||||||
import 'package:pluriwave/modelos/archivo_grabacion.dart';
|
import 'package:pluriwave/modelos/archivo_grabacion.dart';
|
||||||
import 'package:pluriwave/pantallas/pantalla_grabaciones.dart';
|
import 'package:pluriwave/pantallas/pantalla_grabaciones.dart';
|
||||||
import 'package:pluriwave/servicios/servicio_grabacion_radio.dart';
|
import 'package:pluriwave/servicios/servicio_grabacion_radio.dart';
|
||||||
|
import 'package:pluriwave/widgets/pluri_glass_surface.dart';
|
||||||
import 'package:pluriwave/widgets/pluri_push_scaffold.dart';
|
import 'package:pluriwave/widgets/pluri_push_scaffold.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
@@ -137,7 +138,9 @@ void main() {
|
|||||||
);
|
);
|
||||||
await pumpStable(tester);
|
await pumpStable(tester);
|
||||||
|
|
||||||
await tester.tap(find.byIcon(Icons.settings_outlined));
|
// Audit 12.1 (t4:610): the header action icon is `folder_open`, not
|
||||||
|
// a generic gear.
|
||||||
|
await tester.tap(find.byIcon(Icons.folder_open_rounded));
|
||||||
await pumpStable(tester);
|
await pumpStable(tester);
|
||||||
|
|
||||||
expect(find.byType(PluriPushScaffold), findsNWidgets(2));
|
expect(find.byType(PluriPushScaffold), findsNWidgets(2));
|
||||||
@@ -194,6 +197,51 @@ void main() {
|
|||||||
expect(find.byIcon(Icons.play_circle_fill_rounded), findsNWidgets(3));
|
expect(find.byIcon(Icons.play_circle_fill_rounded), findsNWidgets(3));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Item 23 / audit 12.4 (t4:616-619): flat, background-less rows with a
|
||||||
|
// 44x44/radius-12 thumbnail placeholder -- replacing the PluriGlassSurface
|
||||||
|
// + ListTile card, which had no artwork slot at all.
|
||||||
|
testWidgets(
|
||||||
|
'rows are flat -- no ListTile, no per-row PluriGlassSurface -- with a '
|
||||||
|
'44x44/radius-12 thumbnail placeholder',
|
||||||
|
(tester) async {
|
||||||
|
final estado = EstadoGrabacion(
|
||||||
|
servicio: _FakeServicioGrabacionConArchivos([
|
||||||
|
fijaA,
|
||||||
|
fijaB,
|
||||||
|
], maxBytesFijo: 200 * 1024 * 1024),
|
||||||
|
);
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildScreen(
|
||||||
|
estado: estado,
|
||||||
|
reproductor: _ReproductorGrabacionesFake(const {}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await pumpStable(tester);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.byType(ListTile),
|
||||||
|
findsNothing,
|
||||||
|
reason: 'audit 12.4 replaces the ListTile row with a flat Row',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.byType(PluriGlassSurface),
|
||||||
|
findsOneWidget,
|
||||||
|
reason: 'only the storage card keeps a surface -- rows do not',
|
||||||
|
);
|
||||||
|
|
||||||
|
final miniaturas = find.byKey(const ValueKey('fila-grabacion-arte'));
|
||||||
|
expect(miniaturas, findsNWidgets(2));
|
||||||
|
expect(tester.getSize(miniaturas.first), const Size(44, 44));
|
||||||
|
final clip = tester.widget<ClipRRect>(miniaturas.first);
|
||||||
|
expect(
|
||||||
|
(clip.borderRadius as BorderRadius).topLeft,
|
||||||
|
const Radius.circular(12),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
testWidgets('15.2-B: empty folder renders an empty state, not an error', (
|
testWidgets('15.2-B: empty folder renders an empty state, not an error', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
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/tema/pluriwave_tokens.dart';
|
||||||
|
import 'package:pluriwave/widgets/fila_emisora_plana.dart';
|
||||||
|
import 'package:pluriwave/widgets/tarjeta_emisora.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../helpers/fakes.dart';
|
||||||
|
import '../helpers/fakes_alarmas.dart';
|
||||||
|
|
||||||
|
EstadoRadio _estado() => EstadoRadio(
|
||||||
|
audio: FakeServicioAudio(),
|
||||||
|
favoritos: FakeServicioFavoritos(),
|
||||||
|
radio: FakeServicioRadio(),
|
||||||
|
servicioEcualizador: FakeServicioEcualizador(),
|
||||||
|
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
|
||||||
|
iniciarAutomaticamente: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget _host(EstadoRadio estado, Widget child) {
|
||||||
|
return ChangeNotifierProvider<EstadoRadio>.value(
|
||||||
|
value: estado,
|
||||||
|
child: MaterialApp(
|
||||||
|
locale: const Locale('en'),
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: Scaffold(body: child),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Item 23 / audit 4.3 + 6.5 (t4:226-232, 302-306): the shared flat row
|
||||||
|
/// backing Favoritos and search results.
|
||||||
|
void main() {
|
||||||
|
testWidgets(
|
||||||
|
'renders a flat, background-less row -- no TarjetaEmisora underneath',
|
||||||
|
(tester) async {
|
||||||
|
final estado = _estado();
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
final emisora = emisoraDemo(uuid: 'a', nombre: 'Station A');
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_host(estado, FilaEmisoraPlana(emisora: emisora, meta: 'Rock - Spain')),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text('Station A'), findsOneWidget);
|
||||||
|
expect(find.text('Rock - Spain'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
find.byType(TarjetaEmisora),
|
||||||
|
findsNothing,
|
||||||
|
reason: 'a flat row does not reuse the full glass card',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('the thumbnail is 48x48 with a 12 radius (t4:226, t4:302)', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final estado = _estado();
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
final emisora = emisoraDemo(uuid: 'a', nombre: 'Station A');
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_host(estado, FilaEmisoraPlana(emisora: emisora, meta: '')),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
final arte = find.byKey(const ValueKey('fila-emisora-plana-arte'));
|
||||||
|
expect(tester.getSize(arte), const Size(48, 48));
|
||||||
|
final clip = tester.widget<ClipRRect>(arte);
|
||||||
|
expect(
|
||||||
|
(clip.borderRadius as BorderRadius).topLeft,
|
||||||
|
const Radius.circular(12),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('omits the meta line entirely when empty (no stray gap)', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final estado = _estado();
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
final emisora = emisoraDemo(uuid: 'a', nombre: 'Station A');
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_host(estado, FilaEmisoraPlana(emisora: emisora, meta: '')),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text('Station A'), findsOneWidget);
|
||||||
|
// Only the title Text should exist in the info column -- no empty
|
||||||
|
// second Text node.
|
||||||
|
expect(find.text(''), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('BotonReproducirCircular is 44x44, brand-teal-tinted background, '
|
||||||
|
'22px play_arrow icon (t4:230, t4:305)', (tester) async {
|
||||||
|
var tapped = false;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: BotonReproducirCircular(onPressed: () => tapped = true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
final boton = find.byKey(const ValueKey('boton-reproducir-circular'));
|
||||||
|
final material = tester.widget<Material>(boton);
|
||||||
|
expect(material.color, PluriWaveTokens.brand.withValues(alpha: 0.14));
|
||||||
|
expect(material.shape, const CircleBorder());
|
||||||
|
|
||||||
|
final icon = tester.widget<Icon>(find.byIcon(Icons.play_arrow_rounded));
|
||||||
|
expect(icon.size, 22);
|
||||||
|
expect(icon.color, PluriWaveTokens.brand);
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.play_arrow_rounded));
|
||||||
|
expect(tapped, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'BotonFavoritoEmisora toggles favourite status and shows a snackbar '
|
||||||
|
'(t4:304)',
|
||||||
|
(tester) async {
|
||||||
|
final estado = _estado();
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
final emisora = emisoraDemo(uuid: 'a', nombre: 'Station A');
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_host(estado, BotonFavoritoEmisora(emisora: emisora)),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.byIcon(Icons.favorite_outline_rounded), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.favorite_outline_rounded));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.byIcon(Icons.favorite_rounded), findsOneWidget);
|
||||||
|
expect(await estado.esFavorito('a'), isTrue);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user