diff --git a/lib/pantallas/pantalla_favoritos.dart b/lib/pantallas/pantalla_favoritos.dart index 46d4cb1..854073c 100644 --- a/lib/pantallas/pantalla_favoritos.dart +++ b/lib/pantallas/pantalla_favoritos.dart @@ -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( + 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), + ), + ], + ), + ], ); } } diff --git a/lib/pantallas/pantalla_grabaciones.dart b/lib/pantallas/pantalla_grabaciones.dart index d274a57..f1565b2 100644 --- a/lib/pantallas/pantalla_grabaciones.dart +++ b/lib/pantallas/pantalla_grabaciones.dart @@ -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 { 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 { 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( - future: duracion, - builder: (context, snap) { - return Text( - '${formatearFecha(archivo.fecha)} · ' - '${formatearDuracion(snap.data)} · ' - '${formatearBytes(archivo.tamanoBytes)}', - ); - }, - ), - trailing: PopupMenuButton( - 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( + 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( + 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), + ), + ], + ), + ), + ], ), ); } diff --git a/lib/widgets/fila_emisora_plana.dart b/lib/widgets/fila_emisora_plana.dart new file mode 100644 index 0000000..3ef4615 --- /dev/null +++ b/lib/widgets/fila_emisora_plana.dart @@ -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 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 createState() => _BotonFavoritoEmisoraState(); +} + +class _BotonFavoritoEmisoraState extends State { + bool _toggling = false; + + Future _toggle() async { + if (_toggling) return; + setState(() => _toggling = true); + final estado = context.read(); + 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( + (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, + ), + ); +} diff --git a/test/pantallas/pantalla_favoritos_test.dart b/test/pantallas/pantalla_favoritos_test.dart index 30aa562..2729f89 100644 --- a/test/pantallas/pantalla_favoritos_test.dart +++ b/test/pantallas/pantalla_favoritos_test.dart @@ -6,7 +6,9 @@ import 'package:pluriwave/estado/estado_radio.dart'; import 'package:pluriwave/l10n/gen/app_localizations.dart'; import 'package:pluriwave/pantallas/ajustes/pantalla_ajustes_grupos_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/tarjeta_emisora.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -381,6 +383,90 @@ void main() { 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), 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>( + find.byType(PopupMenuButton).first, + ); + boton.onSelected!('remove'); + await pumpStable(tester); + + expect(find.text('Station A'), findsNothing); + }, + ); + }); } void setLargeSurface(WidgetTester tester) { diff --git a/test/pantallas/pantalla_grabaciones_test.dart b/test/pantallas/pantalla_grabaciones_test.dart index 36d4fb4..e614959 100644 --- a/test/pantallas/pantalla_grabaciones_test.dart +++ b/test/pantallas/pantalla_grabaciones_test.dart @@ -8,6 +8,7 @@ import 'package:pluriwave/l10n/gen/app_localizations.dart'; import 'package:pluriwave/modelos/archivo_grabacion.dart'; import 'package:pluriwave/pantallas/pantalla_grabaciones.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:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -137,7 +138,9 @@ void main() { ); 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); expect(find.byType(PluriPushScaffold), findsNWidgets(2)); @@ -194,6 +197,51 @@ void main() { 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(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', ( tester, ) async { diff --git a/test/widgets/fila_emisora_plana_test.dart b/test/widgets/fila_emisora_plana_test.dart new file mode 100644 index 0000000..dd9298a --- /dev/null +++ b/test/widgets/fila_emisora_plana_test.dart @@ -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.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(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(boton); + expect(material.color, PluriWaveTokens.brand.withValues(alpha: 0.14)); + expect(material.shape, const CircleBorder()); + + final icon = tester.widget(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); + }, + ); +}