From 4be2156e58401e75a9933bbdbf5106a7dbad0c72 Mon Sep 17 00:00:00 2001 From: freetlab Date: Thu, 30 Jul 2026 18:26:25 +0200 Subject: [PATCH 1/7] fix(nav,favoritos): unclip the overflow menu and smooth the tab transition Two user-reported bugs from on-device testing. The favourites overflow menu carried `constraints: tightFor(38x42)`, which sizes the POPUP rather than the button -- every item was clipped to its first letter, so users saw "M" and "E" instead of the labels. The existing test passed throughout because find.text matches a Text widget whether or not it is visually clipped; the new guard measures the laid-out width instead. The bottom bar's ink splash had no shape, painting a hard square over the icon, and the active tab's lift, dim, icon size and label all changed instantly while the balloon slid -- the balloon glided and its contents teleported. All four now share the balloon's duration and curve. --- lib/pantallas/pantalla_favoritos.dart | 8 +- lib/widgets/pluri_bottom_navigation.dart | 122 +++++++++++------- test/pantallas/pantalla_favoritos_test.dart | 18 +++ .../widgets/pluri_bottom_navigation_test.dart | 8 +- 4 files changed, 107 insertions(+), 49 deletions(-) diff --git a/lib/pantallas/pantalla_favoritos.dart b/lib/pantallas/pantalla_favoritos.dart index 471f27b..3753a0b 100644 --- a/lib/pantallas/pantalla_favoritos.dart +++ b/lib/pantallas/pantalla_favoritos.dart @@ -465,7 +465,13 @@ class _FilaFavorito extends StatelessWidget { context, ).colorScheme.onSurface.withValues(alpha: 0.45), ), - constraints: const BoxConstraints.tightFor(width: 38, height: 42), + // NO `constraints:` here. That property sizes the POPUP MENU, not + // the button — a tightFor(38x42) clipped every menu item down to + // its first letter ("M" for "Mover a lista", "E" for "Eliminar de + // favoritos"), which is what users actually saw. Constrain the + // tap target instead. + padding: EdgeInsets.zero, + iconSize: 20, onSelected: (accion) { if (accion == 'assign') _asignar(context); if (accion == 'remove') _eliminar(context); diff --git a/lib/widgets/pluri_bottom_navigation.dart b/lib/widgets/pluri_bottom_navigation.dart index 40d1fd1..9aec675 100644 --- a/lib/widgets/pluri_bottom_navigation.dart +++ b/lib/widgets/pluri_bottom_navigation.dart @@ -192,7 +192,10 @@ class PluriBottomNavigation extends StatelessWidget { /// the balloon's opaque fill already covers the seam where the bar's own /// shadow would otherwise show through. List get _shellShadows => [ - BoxShadow(color: Colors.white.withValues(alpha: 0.17), offset: const Offset(0, -1.5)), + BoxShadow( + color: Colors.white.withValues(alpha: 0.17), + offset: const Offset(0, -1.5), + ), BoxShadow( color: Colors.black.withValues(alpha: 0.5), offset: const Offset(0, 14), @@ -214,6 +217,7 @@ class _PluriNavButton extends StatelessWidget { @override Widget build(BuildContext context) { final t = context.pluriTokens; + final motion = context.pluriMotion; return Semantics( button: true, selected: selected, @@ -222,6 +226,11 @@ class _PluriNavButton extends StatelessWidget { type: MaterialType.transparency, child: InkWell( onTap: onTap, + // Without a shape the ink splash and hover highlight paint as a + // full-bleed RECTANGLE over the cell — a hard square sitting on + // top of the icon, which is what users reported. The whole bar is + // built on 999-radius pills; the splash has to follow. + borderRadius: BorderRadius.circular(999), child: Align( alignment: Alignment.bottomCenter, // t4/4a spec: the items row itself is 52px tall — this inner @@ -238,54 +247,75 @@ class _PluriNavButton extends StatelessWidget { // Flutter joins merged labels with `\n`, so screen readers // would announce "Alarmas\nAlarmas" instead of "Alarmas". child: ExcludeSemantics( - child: Transform.translate( - // t4/4a spec: active item lift `translateY(-15px)`. - offset: Offset(0, selected ? -15 : 0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Opacity( - // t4/4a spec: active icon full colour; inactive - // `rgba(242,247,250,.46)` — .46 applied here as - // uniform opacity dims both the fallback Icon - // (already `onSurface` from - // PluriIconVariant.filled) and the real raster - // badge asset identically. - opacity: selected ? 1 : 0.46, - child: PluriIcon( - glyph: item.glyph, - variant: PluriIconVariant.filled, - // t4/4a spec: icon `font-size:25px`/`23px`. - size: selected ? 25 : 23, - color: selected ? t.electricMagenta : null, - // Same ARB string the outer Semantics already - // uses — passing it explicitly skips - // PluriIcon's own AppLocalizations.of lookup - // (excluded from the tree above regardless). - semanticLabel: item.label, - ), - ), - if (selected) ...[ - const SizedBox(height: 3), - Text( - item.label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - // t4/4a spec: label `font-size:11px; - // font-weight:800; line-height:1.25`, brand - // colour. - style: Theme.of( - context, - ).textTheme.labelSmall?.copyWith( - fontSize: 11, - fontWeight: FontWeight.w800, - height: 1.25, - letterSpacing: 0, - color: t.electricMagenta, + // t4/4a spec: active item lift `translateY(-15px)`. Every + // property here used to change INSTANTLY while the balloon + // behind it slid with AnimatedPositioned — the balloon + // glided and its contents teleported, which read as a + // broken transition. All four now share the balloon's own + // duration and curve so the whole tab moves as one. + child: AnimatedSlide( + duration: motion.normal, + curve: Curves.easeOutCubic, + // Slide is expressed in fractions of the child's size; + // the icon column is ~40 tall, so -15px is about -0.375. + offset: Offset(0, selected ? -0.375 : 0), + child: AnimatedSize( + duration: motion.normal, + curve: Curves.easeOutCubic, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedOpacity( + duration: motion.normal, + curve: Curves.easeOutCubic, + // t4/4a spec: active icon full colour; inactive + // `rgba(242,247,250,.46)` — .46 applied here as + // uniform opacity dims both the fallback Icon + // (already `onSurface` from + // PluriIconVariant.filled) and the real raster + // badge asset identically. + opacity: selected ? 1 : 0.46, + child: TweenAnimationBuilder( + duration: motion.normal, + curve: Curves.easeOutCubic, + tween: Tween(end: selected ? 25 : 23), + builder: + (context, size, _) => PluriIcon( + glyph: item.glyph, + variant: PluriIconVariant.filled, + // t4/4a spec: `font-size:25px`/`23px`. + size: size, + color: selected ? t.electricMagenta : null, + // Same ARB string the outer Semantics + // already uses — passing it explicitly + // skips PluriIcon's own + // AppLocalizations.of lookup. + semanticLabel: item.label, + ), ), ), + if (selected) ...[ + const SizedBox(height: 3), + Text( + item.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + // t4/4a spec: label `font-size:11px; + // font-weight:800; line-height:1.25`, brand + // colour. + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith( + fontSize: 11, + fontWeight: FontWeight.w800, + height: 1.25, + letterSpacing: 0, + color: t.electricMagenta, + ), + ), + ], ], - ], + ), ), ), ), diff --git a/test/pantallas/pantalla_favoritos_test.dart b/test/pantallas/pantalla_favoritos_test.dart index 0d14502..623a606 100644 --- a/test/pantallas/pantalla_favoritos_test.dart +++ b/test/pantallas/pantalla_favoritos_test.dart @@ -472,6 +472,24 @@ void main() { expect(find.text('Move to list'), findsOneWidget); expect(find.text('Remove from favorites'), findsOneWidget); expect(find.byType(PopupMenuItem), findsNWidgets(2)); + + // Regression guard for a real user-reported bug: the button carried + // `constraints: BoxConstraints.tightFor(width: 38, height: 42)`, + // which sizes the POPUP MENU rather than the button. Every item was + // clipped to its first letter — users saw "M" and "E", not the + // labels. The three assertions above all PASSED throughout, because + // find.text matches a Text widget in the tree whether or not it is + // visually clipped. Only measuring the laid-out width catches it. + final anchoItem = tester.getSize( + find.byType(PopupMenuItem).first, + ); + expect( + anchoItem.width, + greaterThan(100), + reason: + 'a menu item narrower than its label means the popup is being ' + 'constrained and the text is clipped', + ); }, ); diff --git a/test/widgets/pluri_bottom_navigation_test.dart b/test/widgets/pluri_bottom_navigation_test.dart index 29aa31e..b5a171c 100644 --- a/test/widgets/pluri_bottom_navigation_test.dart +++ b/test/widgets/pluri_bottom_navigation_test.dart @@ -96,13 +96,17 @@ void main() { ); expect(inactiveIcon.color, isNull); - final dimmed = tester.widget( + // AnimatedOpacity, not a plain Opacity: the dim now transitions with the + // balloon instead of snapping. Users reported the bar's contents + // teleporting while the balloon slid — the lift, dim, icon size and + // label all animate together now. + final dimmed = tester.widget( find.ancestor( of: find.descendant( of: find.byKey(PluriBottomNavigation.itemKey(1)), matching: find.byType(PluriIcon), ), - matching: find.byType(Opacity), + matching: find.byType(AnimatedOpacity), ), ); expect(dimmed.opacity, closeTo(0.46, 0.001)); From d1a911e58740573b728734d3c75ca47128473268 Mon Sep 17 00:00:00 2001 From: freetlab Date: Thu, 30 Jul 2026 18:54:31 +0200 Subject: [PATCH 2/7] fix(widgets): share station-art fallback across every surface 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. --- lib/pantallas/pantalla_inicio.dart | 35 +++---- lib/pantallas/pantalla_reproductor.dart | 18 ++-- lib/widgets/fila_emisora_plana.dart | 18 ++-- lib/widgets/mini_reproductor.dart | 18 ++-- lib/widgets/pluri_station_art_fallback.dart | 92 +++++++++++++++++++ lib/widgets/tarjeta_emisora.dart | 50 ++-------- test/pantallas/pantalla_inicio_test.dart | 71 ++++++++++++++ test/pantallas/pantalla_reproductor_test.dart | 27 ++++++ test/widgets/fila_emisora_plana_test.dart | 27 ++++++ test/widgets/mini_reproductor_barra_test.dart | 28 ++++++ .../pluri_station_art_fallback_test.dart | 86 +++++++++++++++++ .../tarjeta_emisora_thumbnail_test.dart | 19 ++++ 12 files changed, 395 insertions(+), 94 deletions(-) create mode 100644 lib/widgets/pluri_station_art_fallback.dart create mode 100644 test/widgets/pluri_station_art_fallback_test.dart diff --git a/lib/pantallas/pantalla_inicio.dart b/lib/pantallas/pantalla_inicio.dart index 4a0ecf6..b52608d 100644 --- a/lib/pantallas/pantalla_inicio.dart +++ b/lib/pantallas/pantalla_inicio.dart @@ -15,6 +15,7 @@ import '../widgets/pluri_glass_surface.dart'; import '../widgets/pluri_layout.dart'; import '../widgets/pluri_premium_widgets.dart'; import '../widgets/pluri_sleep_timer_sheet.dart'; +import '../widgets/pluri_station_art_fallback.dart'; import '../widgets/visualizador_audio.dart'; import 'pantalla_reproductor.dart'; @@ -568,9 +569,9 @@ class _ArteEscuchar extends StatelessWidget { imageUrl: emisora.favicon!, fit: BoxFit.cover, placeholder: (_, __) => _shimmerCuadrado(theme), - errorWidget: (_, __, ___) => _iconoFallback(theme), + errorWidget: (_, __, ___) => _iconoFallback(), ) - : _iconoFallback(theme), + : _iconoFallback(), ), ), ); @@ -582,14 +583,11 @@ class _ArteEscuchar extends StatelessWidget { child: Container(color: theme.colorScheme.surfaceContainerHighest), ); - Widget _iconoFallback(ThemeData theme) => Container( - color: theme.colorScheme.primaryContainer, - child: Icon( - Icons.radio_rounded, - size: 36, - color: theme.colorScheme.onPrimaryContainer, - ), - ); + // 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: 36); } /// The hero's transport row — favorite / EQ toggle / stop / play-pause @@ -909,10 +907,10 @@ class _CeldaTusEmisoras extends StatelessWidget { imageUrl: emisora.favicon!, fit: BoxFit.cover, placeholder: (_, __) => _shimmer(theme), - errorWidget: (_, __, ___) => _iconoFallback(theme), + errorWidget: (_, __, ___) => _iconoFallback(), ); } - return _iconoFallback(theme); + return _iconoFallback(); } Widget _shimmer(ThemeData theme) => shimmer.Shimmer.fromColors( @@ -921,12 +919,9 @@ class _CeldaTusEmisoras extends StatelessWidget { child: Container(color: theme.colorScheme.surfaceContainerHighest), ); - Widget _iconoFallback(ThemeData theme) => Container( - color: theme.colorScheme.primaryContainer, - child: Icon( - Icons.radio_rounded, - size: 20, - color: theme.colorScheme.onPrimaryContainer, - ), - ); + // 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: 20); } diff --git a/lib/pantallas/pantalla_reproductor.dart b/lib/pantallas/pantalla_reproductor.dart index 78d210a..451f9a4 100644 --- a/lib/pantallas/pantalla_reproductor.dart +++ b/lib/pantallas/pantalla_reproductor.dart @@ -20,6 +20,7 @@ import '../widgets/ecualizador_widget.dart'; import '../widgets/pluri_glass_surface.dart'; import '../widgets/pluri_premium_widgets.dart'; import '../widgets/pluri_push_scaffold.dart'; +import '../widgets/pluri_station_art_fallback.dart'; import '../widgets/visualizador_audio.dart'; /// WU14: restructured onto [PluriPushScaffold] (design ADR-2) — this screen @@ -291,10 +292,10 @@ class _ArteReproductor extends StatelessWidget { imageUrl: emisora.favicon!, fit: BoxFit.cover, placeholder: (_, __) => _shimmer(theme), - errorWidget: (_, __, ___) => _iconoFallback(theme), + errorWidget: (_, __, ___) => _iconoFallback(), ) else - _iconoFallback(theme), + _iconoFallback(), if (cargando) Container( color: Colors.black45, @@ -333,14 +334,11 @@ class _ArteReproductor extends StatelessWidget { child: Container(color: theme.colorScheme.surfaceContainerHighest), ); - Widget _iconoFallback(ThemeData theme) => Container( - color: theme.colorScheme.primaryContainer, - child: Icon( - Icons.radio_rounded, - size: 80, - color: theme.colorScheme.onPrimaryContainer, - ), - ); + // 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: 80); } /// Audit 2.2 (t4 lines 108-109): a full-bleed blurred backdrop of the diff --git a/lib/widgets/fila_emisora_plana.dart b/lib/widgets/fila_emisora_plana.dart index 3ef4615..2d5ee6f 100644 --- a/lib/widgets/fila_emisora_plana.dart +++ b/lib/widgets/fila_emisora_plana.dart @@ -8,6 +8,7 @@ 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 @@ -234,10 +235,10 @@ class _ArteFilaEmisora extends StatelessWidget { imageUrl: emisora.favicon!, fit: BoxFit.cover, placeholder: (_, __) => _shimmer(theme), - errorWidget: (_, __, ___) => _iconoFallback(theme), + errorWidget: (_, __, ___) => _iconoFallback(), ); } - return _iconoFallback(theme); + return _iconoFallback(); } Widget _shimmer(ThemeData theme) => shimmer.Shimmer.fromColors( @@ -246,12 +247,9 @@ class _ArteFilaEmisora extends StatelessWidget { 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, - ), - ); + // 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); } diff --git a/lib/widgets/mini_reproductor.dart b/lib/widgets/mini_reproductor.dart index a7e126c..9a0fc2f 100644 --- a/lib/widgets/mini_reproductor.dart +++ b/lib/widgets/mini_reproductor.dart @@ -10,6 +10,7 @@ import '../modelos/emisora.dart'; import '../pantallas/pantalla_reproductor.dart'; import '../servicios/servicio_audio.dart'; import '../tema/pluriwave_theme.dart'; +import 'pluri_station_art_fallback.dart'; /// Barra inferior persistente con controles básicos de reproducción. /// Toca la barra para abrir PantallaReproductor completa. @@ -318,9 +319,9 @@ class _ArteMiniReproductor extends StatelessWidget { imageUrl: emisora.favicon!, fit: BoxFit.cover, placeholder: (_, __) => _shimmer(theme), - errorWidget: (_, __, ___) => _iconoFallback(theme), + errorWidget: (_, __, ___) => _iconoFallback(), ) - : _iconoFallback(theme), + : _iconoFallback(), ), ); } @@ -331,12 +332,9 @@ class _ArteMiniReproductor extends StatelessWidget { child: Container(color: theme.colorScheme.surfaceContainerHighest), ); - Widget _iconoFallback(ThemeData theme) => Container( - color: theme.colorScheme.primaryContainer, - child: Icon( - Icons.radio_rounded, - size: 20, - color: theme.colorScheme.onPrimaryContainer, - ), - ); + // 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: 20); } diff --git a/lib/widgets/pluri_station_art_fallback.dart b/lib/widgets/pluri_station_art_fallback.dart new file mode 100644 index 0000000..f4cf67f --- /dev/null +++ b/lib/widgets/pluri_station_art_fallback.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; + +import '../l10n/gen/app_localizations.dart'; +import '../tema/pluriwave_theme.dart'; +import 'pluri_icon.dart'; + +/// Shared station-art fallback (feedback-pruebas issue 6). +/// +/// Before this widget existed, `TarjetaEmisora` had the only good fallback — +/// a deterministic pick from 4 bundled illustrations (`_fallbackArtFor`), +/// with a gradient + player glyph as a last resort if the asset itself ever +/// fails to decode. Every OTHER surface that can render a station without +/// artwork (`FilaEmisoraPlana`'s flat rows, the Escuchar hero, the "Tus +/// emisoras" grid cell, the mini player, the full player) had its own, +/// separate, much poorer copy: a flat `primaryContainer`-coloured square +/// with a bare `radio_rounded` icon. This widget is the ONE shared +/// implementation every one of those call sites now uses instead. +/// +/// The selection formula is pinned EXACTLY as `TarjetaEmisora` originally +/// had it (`seed.codeUnits.fold(0, (a, b) => a + b) % 4`, asset order +/// aurora/cosmic/pulse/nova) — `lib/servicios/navegacion_auto.dart` (a +/// protected, empty-diff file) independently mirrors this SAME formula for +/// Android Auto's own drawable-resource rotation +/// (`test/servicios/navegacion_auto_test.dart`'s `indiceArtePara`/ +/// `artUriPara` tests assert it inline, not by importing this widget) — +/// changing the order or the modulo here would silently desync the two +/// without either test suite noticing until an actual device compared them +/// side by side. +class PluriStationArtFallback extends StatelessWidget { + const PluriStationArtFallback({ + super.key, + required this.seed, + this.iconSize = 22, + }); + + /// Typically the station's `uuid` — the seed that deterministically picks + /// one of the 4 bundled arts below. + final String seed; + + /// Size of the centred player glyph drawn over the art. + final double iconSize; + + static const _arts = [ + 'assets/images/station_art_aurora.png', + 'assets/images/station_art_cosmic.png', + 'assets/images/station_art_pulse.png', + 'assets/images/station_art_nova.png', + ]; + + /// Exposed so other call sites (and tests) can assert which asset a given + /// seed resolves to without needing to render the widget. + static String artFor(String seed) { + final index = seed.codeUnits.fold(0, (a, b) => a + b) % _arts.length; + return _arts[index]; + } + + @override + Widget build(BuildContext context) { + return Stack( + fit: StackFit.expand, + children: [ + Image.asset( + artFor(seed), + fit: BoxFit.cover, + errorBuilder: + (_, __, ___) => DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + context.pluriTokens.deepViolet, + context.pluriTokens.electricMagenta.withValues( + alpha: 0.8, + ), + ], + ), + ), + ), + ), + Center( + child: PluriIcon( + glyph: PluriIconGlyph.player, + variant: PluriIconVariant.activeGlow, + size: iconSize, + semanticLabel: AppLocalizations.of(context).stationIconLabel, + ), + ), + ], + ); + } +} diff --git a/lib/widgets/tarjeta_emisora.dart b/lib/widgets/tarjeta_emisora.dart index 40af058..40a6e41 100644 --- a/lib/widgets/tarjeta_emisora.dart +++ b/lib/widgets/tarjeta_emisora.dart @@ -10,6 +10,7 @@ import '../modelos/emisora.dart'; import '../tema/pluriwave_theme.dart'; import 'pluri_glass_surface.dart'; import 'pluri_icon.dart'; +import 'pluri_station_art_fallback.dart'; /// Tarjeta compacta para mostrar una emisora en listas y grids. /// Incluye botón de favorito visible en ambos modos. @@ -302,51 +303,12 @@ class _TarjetaEmisoraState extends State { ); } + // Issue 6 (feedback-pruebas): extracted into the shared + // `PluriStationArtFallback` so every surface that can render a station + // without artwork shows the SAME fallback — see that widget's own doc + // comment for why the selection formula must never drift. Widget _iconoFallback(double size) { - final art = _fallbackArtFor(widget.emisora.uuid); - return Stack( - fit: StackFit.expand, - children: [ - Image.asset( - art, - fit: BoxFit.cover, - errorBuilder: - (_, __, ___) => DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - context.pluriTokens.deepViolet, - context.pluriTokens.electricMagenta.withValues( - alpha: 0.8, - ), - ], - ), - ), - ), - ), - Center( - child: PluriIcon( - glyph: PluriIconGlyph.player, - variant: PluriIconVariant.activeGlow, - size: size, - semanticLabel: AppLocalizations.of(context).stationIconLabel, - ), - ), - ], - ); - } - - String _fallbackArtFor(String seed) { - const arts = [ - 'assets/images/station_art_aurora.png', - 'assets/images/station_art_cosmic.png', - 'assets/images/station_art_pulse.png', - 'assets/images/station_art_nova.png', - ]; - final index = seed.codeUnits.fold(0, (a, b) => a + b) % arts.length; - return arts[index]; + return PluriStationArtFallback(seed: widget.emisora.uuid, iconSize: size); } } diff --git a/test/pantallas/pantalla_inicio_test.dart b/test/pantallas/pantalla_inicio_test.dart index 038f1bd..a7a980d 100644 --- a/test/pantallas/pantalla_inicio_test.dart +++ b/test/pantallas/pantalla_inicio_test.dart @@ -11,6 +11,7 @@ import 'package:pluriwave/l10n/gen/app_localizations.dart'; import 'package:pluriwave/modelos/emisora.dart'; import 'package:pluriwave/pantallas/pantalla_inicio.dart'; import 'package:pluriwave/widgets/pluri_root_header.dart'; +import 'package:pluriwave/widgets/pluri_station_art_fallback.dart'; import 'package:pluriwave/widgets/visualizador_audio.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -698,6 +699,76 @@ void main() { ); }, ); + + testWidgets('issue 6 (feedback-pruebas): the Escuchar hero shows the shared ' + 'PluriStationArtFallback for a station with no favicon, not a flat ' + 'coloured square', (tester) async { + _setLargeSurfaceSize(tester); + final favoritos = FakeServicioFavoritos(); + final estado = EstadoRadio( + audio: FakeServicioAudio(), + favoritos: favoritos, + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + servicioGrabacion: FakeServicioGrabacionRadio(), + resolverArchivoCustom: _archivoCustomVacio, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + await tester.runAsync(estado.inicializar); + final sonando = emisoraDemo(uuid: 'sin-arte', nombre: 'Sin Arte'); + await tester.runAsync(() => estado.reproducir(sonando)); + + await tester.pumpWidget( + _conProviders(estado, _testApp(const PantallaInicio())), + ); + await _pumpBounded(tester); + + expect( + find.byType(PluriStationArtFallback), + findsWidgets, + reason: + 'issue 6: the hero must reach the shared fallback, not its own ' + 'flat primaryContainer square', + ); + }); + + testWidgets( + 'issue 6 (feedback-pruebas): a "Tus emisoras" grid cell shows the shared ' + 'PluriStationArtFallback for a favourite with no favicon', + (tester) async { + _setLargeSurfaceSize(tester); + final favoritos = FakeServicioFavoritos(); + final estado = EstadoRadio( + audio: FakeServicioAudio(), + favoritos: favoritos, + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + servicioGrabacion: FakeServicioGrabacionRadio(), + resolverArchivoCustom: _archivoCustomVacio, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + await tester.runAsync(estado.inicializar); + await favoritos.agregar( + emisoraDemo(uuid: 'grid-sin-arte', nombre: 'Grid Sin Arte'), + ); + await estado.cargarFavoritos(); + + await tester.pumpWidget( + _conProviders(estado, _testApp(const PantallaInicio())), + ); + await _pumpStableFrame(tester); + + expect( + find.byType(PluriStationArtFallback), + findsWidgets, + reason: + 'issue 6: the grid cell must reach the shared fallback, not its ' + 'own flat primaryContainer square', + ); + }, + ); } /// Mirrors the app.dart wiring: EstadoRadio owns the domain notifiers and diff --git a/test/pantallas/pantalla_reproductor_test.dart b/test/pantallas/pantalla_reproductor_test.dart index ab9d3fc..3d00439 100644 --- a/test/pantallas/pantalla_reproductor_test.dart +++ b/test/pantallas/pantalla_reproductor_test.dart @@ -12,6 +12,7 @@ import 'package:pluriwave/pantallas/pantalla_reproductor.dart'; import 'package:pluriwave/tema/pluriwave_tokens.dart'; import 'package:pluriwave/widgets/ecualizador_widget.dart'; import 'package:pluriwave/widgets/pluri_glass_surface.dart'; +import 'package:pluriwave/widgets/pluri_station_art_fallback.dart'; import 'package:pluriwave/widgets/visualizador_audio.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -704,4 +705,30 @@ void main() { }, ); }); + + group('issue 6 (feedback-pruebas): shared station-art fallback', () { + testWidgets( + 'a station with no favicon shows the shared PluriStationArtFallback, ' + 'not a flat coloured square', + (tester) async { + // `emisora` (the file-level fixture) never sets a favicon. + final estado = crearEstado(); + addTearDown(estado.dispose); + + await montarPantalla(tester, estado); + + final arte = find.byKey(const Key('player-hero-art')); + expect( + find.descendant( + of: arte, + matching: find.byType(PluriStationArtFallback), + ), + findsOneWidget, + reason: + 'issue 6: the full player must reach the shared fallback, ' + 'not its own flat primaryContainer square', + ); + }, + ); + }); } diff --git a/test/widgets/fila_emisora_plana_test.dart b/test/widgets/fila_emisora_plana_test.dart index dd9298a..2c18f35 100644 --- a/test/widgets/fila_emisora_plana_test.dart +++ b/test/widgets/fila_emisora_plana_test.dart @@ -4,6 +4,7 @@ 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/pluri_station_art_fallback.dart'; import 'package:pluriwave/widgets/tarjeta_emisora.dart'; import 'package:provider/provider.dart'; @@ -77,6 +78,32 @@ void main() { ); }); + testWidgets( + 'issue 6 (feedback-pruebas): a station with no favicon shows the shared ' + 'PluriStationArtFallback, not a flat coloured square', + (tester) async { + final estado = _estado(); + addTearDown(estado.dispose); + final emisora = emisoraDemo(uuid: 'sin-arte', nombre: 'Sin Arte'); + + await tester.pumpWidget( + _host(estado, FilaEmisoraPlana(emisora: emisora, meta: '')), + ); + await tester.pump(); + + expect( + find.descendant( + of: find.byKey(const ValueKey('fila-emisora-plana-arte')), + matching: find.byType(PluriStationArtFallback), + ), + findsOneWidget, + reason: + 'issue 6: the flat row must reach the shared fallback, not its ' + 'own flat primaryContainer square', + ); + }, + ); + testWidgets('omits the meta line entirely when empty (no stray gap)', ( tester, ) async { diff --git a/test/widgets/mini_reproductor_barra_test.dart b/test/widgets/mini_reproductor_barra_test.dart index bfa8fd0..d3c00bd 100644 --- a/test/widgets/mini_reproductor_barra_test.dart +++ b/test/widgets/mini_reproductor_barra_test.dart @@ -5,6 +5,7 @@ import 'package:pluriwave/l10n/gen/app_localizations.dart'; import 'package:pluriwave/tema/pluriwave_theme.dart'; import 'package:pluriwave/tema/pluriwave_tokens.dart'; import 'package:pluriwave/widgets/mini_reproductor.dart'; +import 'package:pluriwave/widgets/pluri_station_art_fallback.dart'; import 'package:pluriwave/widgets/visualizador_audio.dart'; import 'package:provider/provider.dart'; @@ -75,6 +76,33 @@ void main() { }, ); + testWidgets( + 'issue 6 (feedback-pruebas): a station with no favicon shows the shared ' + 'PluriStationArtFallback, not a flat coloured square', + (tester) async { + final estado = _estadoConEmisora(); + addTearDown(estado.dispose); + await estado.reproducir( + emisoraDemo(uuid: 'sin-arte', nombre: 'Sin Arte'), + ); + + await tester.pumpWidget(_hostFor(estado)); + await tester.pump(); + + final arte = find.byKey(const ValueKey('mini-reproductor-arte')); + expect( + find.descendant( + of: arte, + matching: find.byType(PluriStationArtFallback), + ), + findsOneWidget, + reason: + 'issue 6: the mini player must reach the shared fallback, not ' + 'its own flat primaryContainer square', + ); + }, + ); + testWidgets( 'is opaque -- no BackdropFilter -- unlike the former glass pill', (tester) async { diff --git a/test/widgets/pluri_station_art_fallback_test.dart b/test/widgets/pluri_station_art_fallback_test.dart new file mode 100644 index 0000000..2dddf39 --- /dev/null +++ b/test/widgets/pluri_station_art_fallback_test.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/l10n/gen/app_localizations.dart'; +import 'package:pluriwave/widgets/pluri_icon.dart'; +import 'package:pluriwave/widgets/pluri_station_art_fallback.dart'; + +/// Issue 6 (feedback-pruebas): the shared station-art fallback extracted +/// from `TarjetaEmisora`'s original `_fallbackArtFor`. The formula and asset +/// order are pinned exactly — `navegacion_auto_test.dart`'s +/// `indiceArtePara`/`artUriPara` tests mirror the SAME formula independently +/// (protected file, empty diff vs main) and must keep agreeing with this +/// widget without either suite importing the other. +void main() { + Widget host(Widget child) { + return MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: SizedBox(width: 60, height: 60, child: child)), + ); + } + + test('artFor reproduces the canonical aurora/cosmic/pulse/nova order ' + '(same formula as navegacion_auto.dart\'s indiceArtePara)', () { + // Single-letter seeds whose codeUnit % 4 covers all 4 indices, mirroring + // navegacion_auto_test.dart's own fixture exactly: 'd'(100)->0 aurora, + // 'a'(97)->1 cosmic, 'b'(98)->2 pulse, 'c'(99)->3 nova. + expect( + PluriStationArtFallback.artFor('d'), + 'assets/images/station_art_aurora.png', + ); + expect( + PluriStationArtFallback.artFor('a'), + 'assets/images/station_art_cosmic.png', + ); + expect( + PluriStationArtFallback.artFor('b'), + 'assets/images/station_art_pulse.png', + ); + expect( + PluriStationArtFallback.artFor('c'), + 'assets/images/station_art_nova.png', + ); + }); + + test('artFor is deterministic for a given seed (same station always picks ' + 'the same art)', () { + const seed = 'uuid-1234-abcd-real-looking'; + expect( + PluriStationArtFallback.artFor(seed), + PluriStationArtFallback.artFor(seed), + ); + }); + + testWidgets( + 'renders the deterministic station-art asset plus a centred player glyph', + (tester) async { + await tester.pumpWidget( + host(const PluriStationArtFallback(seed: 'd', iconSize: 22)), + ); + await tester.pump(); + + // Scoped by asset-name prefix, not `find.byType(Image).first` — the + // player glyph (`PluriIcon`) ALSO renders via its own `Image.asset` + // internally, so a bare type match would be ambiguous. + final arte = tester + .widgetList(find.byType(Image)) + .firstWhere( + (img) => + img.image is AssetImage && + (img.image as AssetImage).assetName.startsWith( + 'assets/images/station_art_', + ), + ); + expect( + (arte.image as AssetImage).assetName, + 'assets/images/station_art_aurora.png', + ); + + final icono = tester.widget(find.byType(PluriIcon)); + expect(icono.glyph, PluriIconGlyph.player); + expect(icono.variant, PluriIconVariant.activeGlow); + expect(icono.size, 22); + }, + ); +} diff --git a/test/widgets/tarjeta_emisora_thumbnail_test.dart b/test/widgets/tarjeta_emisora_thumbnail_test.dart index 34eda32..52f5dab 100644 --- a/test/widgets/tarjeta_emisora_thumbnail_test.dart +++ b/test/widgets/tarjeta_emisora_thumbnail_test.dart @@ -2,6 +2,7 @@ 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/widgets/pluri_station_art_fallback.dart'; import 'package:pluriwave/widgets/tarjeta_emisora.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -73,6 +74,24 @@ void main() { expect(tester.getSize(thumbnailClip), const Size(48, 48)); }); + testWidgets( + 'issue 6 (feedback-pruebas): a station with no favicon renders via the ' + 'shared PluriStationArtFallback (extracted from this exact fallback)', + (tester) async { + await tester.pumpWidget( + host( + TarjetaEmisora( + emisora: emisoraDemo(uuid: 'sin-arte', nombre: 'Sin Arte'), + esCompacta: true, + ), + ), + ); + await tester.pump(); + + expect(find.byType(PluriStationArtFallback), findsOneWidget); + }, + ); + testWidgets('esCompacta shimmer placeholder is a square block, not a ' 'circle', (tester) async { await tester.pumpWidget( From c7e1a212cac7ef609b44e3d9b7e1258d82fb4239 Mon Sep 17 00:00:00 2001 From: freetlab Date: Thu, 30 Jul 2026 19:05:28 +0200 Subject: [PATCH 3/7] fix(vacaciones): edit and delete vacation ranges Vacaciones ranges could be created but never edited or removed -- EstadoAlarmas already had crearRangoVacaciones/eliminarRangoVacaciones with no UI affordance reaching them, and no update path at all. Add EstadoAlarmas.editarRangoVacaciones and wire tap-to-edit / swipe-to-delete (with confirmation) onto every range card, mirroring the alarm list's own Dismissible + confirm-dialog pattern exactly. This covers the active-range hero too: a freshly created range is active immediately and only ever renders there, never in the scheduled/past lists, so it needed the same affordances or a user's very first range could never be fixed. --- lib/estado/estado_alarmas.dart | 13 + lib/l10n/app_ar.arb | 3 + lib/l10n/app_bn.arb | 3 + lib/l10n/app_de.arb | 3 + lib/l10n/app_en.arb | 3 + lib/l10n/app_es.arb | 3 + lib/l10n/app_fr.arb | 3 + lib/l10n/app_hi.arb | 3 + lib/l10n/app_id.arb | 3 + lib/l10n/app_it.arb | 3 + lib/l10n/app_ja.arb | 3 + lib/l10n/app_pt.arb | 3 + lib/l10n/app_ru.arb | 3 + lib/l10n/app_zh.arb | 3 + lib/l10n/gen/app_localizations.dart | 18 + lib/l10n/gen/app_localizations_ar.dart | 9 + lib/l10n/gen/app_localizations_bn.dart | 10 + lib/l10n/gen/app_localizations_de.dart | 10 + lib/l10n/gen/app_localizations_en.dart | 9 + lib/l10n/gen/app_localizations_es.dart | 10 + lib/l10n/gen/app_localizations_fr.dart | 9 + lib/l10n/gen/app_localizations_hi.dart | 9 + lib/l10n/gen/app_localizations_id.dart | 10 + lib/l10n/gen/app_localizations_it.dart | 10 + lib/l10n/gen/app_localizations_ja.dart | 9 + lib/l10n/gen/app_localizations_pt.dart | 9 + lib/l10n/gen/app_localizations_ru.dart | 9 + lib/l10n/gen/app_localizations_zh.dart | 9 + lib/pantallas/pantalla_vacaciones.dart | 389 +++++++++++++------ test/pantallas/pantalla_vacaciones_test.dart | 169 ++++++++ 30 files changed, 639 insertions(+), 111 deletions(-) diff --git a/lib/estado/estado_alarmas.dart b/lib/estado/estado_alarmas.dart index 47269d4..8cdb7c3 100644 --- a/lib/estado/estado_alarmas.dart +++ b/lib/estado/estado_alarmas.dart @@ -354,6 +354,19 @@ class EstadoAlarmas extends ChangeNotifier { await guardarVacaciones(nuevos); } + /// Issue 1 (feedback-pruebas): replaces the range with the same [id] in + /// place -- the counterpart `crearRangoVacaciones`/`eliminarRangoVacaciones` + /// were missing before this fix, leaving no way to fix a mistake in an + /// already-saved range (including the currently ACTIVE one, since a + /// freshly created range starts active immediately). + Future editarRangoVacaciones(RangoVacaciones rango) async { + final nuevos = [ + for (final actual in _vacaciones) + if (actual.id == rango.id) rango else actual, + ]; + await guardarVacaciones(nuevos); + } + // ── Vacation queries (design ADR-6, WU9) ────────────────────────────── // Four PURE queries: none writes, none reschedules, none touches the // native bridge. Read-only over `_alarmas`/`_vacaciones`. Every method diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 9c9be9c..92199c0 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -468,6 +468,9 @@ "deleteRangeTooltip": "حذف النطاق", "vacationsDefaultName": "إجازات", "newVacationRangeTitle": "نطاق إجازة جديد", + "editVacationRangeTitle": "تعديل نطاق الإجازة", + "vacationDeleteConfirmTitle": "هل تريد حذف نطاق الإجازة؟", + "vacationDeleteConfirmMessage": "لا يمكن التراجع عن هذا الإجراء.", "startField": "البداية", "endField": "النهاية", "saveRangeAction": "حفظ النطاق", diff --git a/lib/l10n/app_bn.arb b/lib/l10n/app_bn.arb index b0dfc1d..ec8b0f0 100644 --- a/lib/l10n/app_bn.arb +++ b/lib/l10n/app_bn.arb @@ -468,6 +468,9 @@ "deleteRangeTooltip": "পরিসর মুছুন", "vacationsDefaultName": "ছুটি", "newVacationRangeTitle": "নতুন ছুটির পরিসর", + "editVacationRangeTitle": "ছুটির পরিসর সম্পাদনা করুন", + "vacationDeleteConfirmTitle": "ছুটির পরিসর মুছবেন?", + "vacationDeleteConfirmMessage": "এই পদক্ষেপ ফিরিয়ে নেওয়া যাবে না।", "startField": "শুরু", "endField": "শেষ", "saveRangeAction": "পরিসর সংরক্ষণ করুন", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index e7d9daa..12ddab6 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -468,6 +468,9 @@ "deleteRangeTooltip": "Zeitraum löschen", "vacationsDefaultName": "Ferien", "newVacationRangeTitle": "Neuer Ferienzeitraum", + "editVacationRangeTitle": "Ferienzeitraum bearbeiten", + "vacationDeleteConfirmTitle": "Ferienzeitraum löschen?", + "vacationDeleteConfirmMessage": "Dies kann nicht rückgängig gemacht werden.", "startField": "Beginn", "endField": "Ende", "saveRangeAction": "Zeitraum speichern", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index f9f3cd1..f57b44d 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -580,6 +580,9 @@ "vacationNoActiveRangeHint": "No active vacation range right now.", "vacationsDefaultName": "Vacation", "newVacationRangeTitle": "New vacation range", + "editVacationRangeTitle": "Edit vacation range", + "vacationDeleteConfirmTitle": "Delete vacation range?", + "vacationDeleteConfirmMessage": "This can't be undone.", "startField": "Start", "endField": "End", "saveRangeAction": "Save range", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index ba95b6e..835e4ac 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -580,6 +580,9 @@ "vacationNoActiveRangeHint": "No hay un rango de vacaciones activo ahora mismo.", "vacationsDefaultName": "Vacaciones", "newVacationRangeTitle": "Nuevo rango de vacaciones", + "editVacationRangeTitle": "Editar rango de vacaciones", + "vacationDeleteConfirmTitle": "¿Eliminar rango de vacaciones?", + "vacationDeleteConfirmMessage": "Esta acción no se puede deshacer.", "startField": "Inicio", "endField": "Fin", "saveRangeAction": "Guardar rango", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index f8390dd..bb4d7a9 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -468,6 +468,9 @@ "deleteRangeTooltip": "Supprimer la période", "vacationsDefaultName": "Vacances", "newVacationRangeTitle": "Nouvelle période de vacances", + "editVacationRangeTitle": "Modifier la période de vacances", + "vacationDeleteConfirmTitle": "Supprimer la période de vacances ?", + "vacationDeleteConfirmMessage": "Cette action est irréversible.", "startField": "Début", "endField": "Fin", "saveRangeAction": "Enregistrer la période", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index d42f3f3..b9e4a9c 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -468,6 +468,9 @@ "deleteRangeTooltip": "अवधि हटाएँ", "vacationsDefaultName": "छुट्टियाँ", "newVacationRangeTitle": "नई छुट्टी अवधि", + "editVacationRangeTitle": "छुट्टी अवधि संपादित करें", + "vacationDeleteConfirmTitle": "छुट्टी अवधि हटाएं?", + "vacationDeleteConfirmMessage": "इसे वापस नहीं लिया जा सकता।", "startField": "शुरुआत", "endField": "समाप्ति", "saveRangeAction": "अवधि सहेजें", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index ea625ca..b16c871 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -468,6 +468,9 @@ "deleteRangeTooltip": "Hapus rentang", "vacationsDefaultName": "Liburan", "newVacationRangeTitle": "Rentang liburan baru", + "editVacationRangeTitle": "Edit rentang liburan", + "vacationDeleteConfirmTitle": "Hapus rentang liburan?", + "vacationDeleteConfirmMessage": "Tindakan ini tidak dapat dibatalkan.", "startField": "Mulai", "endField": "Akhir", "saveRangeAction": "Simpan rentang", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index a4a8174..8e26513 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -468,6 +468,9 @@ "deleteRangeTooltip": "Elimina periodo", "vacationsDefaultName": "Vacanze", "newVacationRangeTitle": "Nuovo periodo di vacanza", + "editVacationRangeTitle": "Modifica periodo di vacanza", + "vacationDeleteConfirmTitle": "Eliminare il periodo di vacanza?", + "vacationDeleteConfirmMessage": "Questa azione non può essere annullata.", "startField": "Inizio", "endField": "Fine", "saveRangeAction": "Salva periodo", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 3a4256f..432b52d 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -468,6 +468,9 @@ "deleteRangeTooltip": "期間を削除", "vacationsDefaultName": "休暇", "newVacationRangeTitle": "新しい休暇期間", + "editVacationRangeTitle": "休暇期間を編集", + "vacationDeleteConfirmTitle": "休暇期間を削除しますか?", + "vacationDeleteConfirmMessage": "この操作は元に戻せません。", "startField": "開始", "endField": "終了", "saveRangeAction": "期間を保存", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 8ded454..545a927 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -468,6 +468,9 @@ "deleteRangeTooltip": "Excluir período", "vacationsDefaultName": "Férias", "newVacationRangeTitle": "Novo período de férias", + "editVacationRangeTitle": "Editar período de férias", + "vacationDeleteConfirmTitle": "Excluir período de férias?", + "vacationDeleteConfirmMessage": "Esta ação não pode ser desfeita.", "startField": "Início", "endField": "Fim", "saveRangeAction": "Salvar período", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index d14cc19..98166f2 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -468,6 +468,9 @@ "deleteRangeTooltip": "Удалить период", "vacationsDefaultName": "Отпуск", "newVacationRangeTitle": "Новый период отпуска", + "editVacationRangeTitle": "Изменить период отпуска", + "vacationDeleteConfirmTitle": "Удалить период отпуска?", + "vacationDeleteConfirmMessage": "Это действие нельзя отменить.", "startField": "Начало", "endField": "Конец", "saveRangeAction": "Сохранить период", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 37fc3ce..dc2925c 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -468,6 +468,9 @@ "deleteRangeTooltip": "删除范围", "vacationsDefaultName": "假期", "newVacationRangeTitle": "新的假期范围", + "editVacationRangeTitle": "编辑假期范围", + "vacationDeleteConfirmTitle": "删除假期范围?", + "vacationDeleteConfirmMessage": "此操作无法撤销。", "startField": "开始", "endField": "结束", "saveRangeAction": "保存范围", diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 4620764..1f62743 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -2078,6 +2078,24 @@ abstract class AppLocalizations { /// **'Nuevo rango de vacaciones'** String get newVacationRangeTitle; + /// No description provided for @editVacationRangeTitle. + /// + /// In es, this message translates to: + /// **'Editar rango de vacaciones'** + String get editVacationRangeTitle; + + /// No description provided for @vacationDeleteConfirmTitle. + /// + /// In es, this message translates to: + /// **'¿Eliminar rango de vacaciones?'** + String get vacationDeleteConfirmTitle; + + /// No description provided for @vacationDeleteConfirmMessage. + /// + /// In es, this message translates to: + /// **'Esta acción no se puede deshacer.'** + String get vacationDeleteConfirmMessage; + /// No description provided for @startField. /// /// In es, this message translates to: diff --git a/lib/l10n/gen/app_localizations_ar.dart b/lib/l10n/gen/app_localizations_ar.dart index 68d5a15..722038b 100644 --- a/lib/l10n/gen/app_localizations_ar.dart +++ b/lib/l10n/gen/app_localizations_ar.dart @@ -1133,6 +1133,15 @@ class AppLocalizationsAr extends AppLocalizations { @override String get newVacationRangeTitle => 'نطاق إجازة جديد'; + @override + String get editVacationRangeTitle => 'تعديل نطاق الإجازة'; + + @override + String get vacationDeleteConfirmTitle => 'هل تريد حذف نطاق الإجازة؟'; + + @override + String get vacationDeleteConfirmMessage => 'لا يمكن التراجع عن هذا الإجراء.'; + @override String get startField => 'البداية'; diff --git a/lib/l10n/gen/app_localizations_bn.dart b/lib/l10n/gen/app_localizations_bn.dart index 0790578..5627416 100644 --- a/lib/l10n/gen/app_localizations_bn.dart +++ b/lib/l10n/gen/app_localizations_bn.dart @@ -1140,6 +1140,16 @@ class AppLocalizationsBn extends AppLocalizations { @override String get newVacationRangeTitle => 'নতুন ছুটির পরিসর'; + @override + String get editVacationRangeTitle => 'ছুটির পরিসর সম্পাদনা করুন'; + + @override + String get vacationDeleteConfirmTitle => 'ছুটির পরিসর মুছবেন?'; + + @override + String get vacationDeleteConfirmMessage => + 'এই পদক্ষেপ ফিরিয়ে নেওয়া যাবে না।'; + @override String get startField => 'শুরু'; diff --git a/lib/l10n/gen/app_localizations_de.dart b/lib/l10n/gen/app_localizations_de.dart index dd17ae6..4c63b22 100644 --- a/lib/l10n/gen/app_localizations_de.dart +++ b/lib/l10n/gen/app_localizations_de.dart @@ -1142,6 +1142,16 @@ class AppLocalizationsDe extends AppLocalizations { @override String get newVacationRangeTitle => 'Neuer Ferienzeitraum'; + @override + String get editVacationRangeTitle => 'Ferienzeitraum bearbeiten'; + + @override + String get vacationDeleteConfirmTitle => 'Ferienzeitraum löschen?'; + + @override + String get vacationDeleteConfirmMessage => + 'Dies kann nicht rückgängig gemacht werden.'; + @override String get startField => 'Beginn'; diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index df75acf..a50957d 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -1133,6 +1133,15 @@ class AppLocalizationsEn extends AppLocalizations { @override String get newVacationRangeTitle => 'New vacation range'; + @override + String get editVacationRangeTitle => 'Edit vacation range'; + + @override + String get vacationDeleteConfirmTitle => 'Delete vacation range?'; + + @override + String get vacationDeleteConfirmMessage => 'This can\'t be undone.'; + @override String get startField => 'Start'; diff --git a/lib/l10n/gen/app_localizations_es.dart b/lib/l10n/gen/app_localizations_es.dart index 4b436bf..55857ea 100644 --- a/lib/l10n/gen/app_localizations_es.dart +++ b/lib/l10n/gen/app_localizations_es.dart @@ -1139,6 +1139,16 @@ class AppLocalizationsEs extends AppLocalizations { @override String get newVacationRangeTitle => 'Nuevo rango de vacaciones'; + @override + String get editVacationRangeTitle => 'Editar rango de vacaciones'; + + @override + String get vacationDeleteConfirmTitle => '¿Eliminar rango de vacaciones?'; + + @override + String get vacationDeleteConfirmMessage => + 'Esta acción no se puede deshacer.'; + @override String get startField => 'Inicio'; diff --git a/lib/l10n/gen/app_localizations_fr.dart b/lib/l10n/gen/app_localizations_fr.dart index f546287..1232e33 100644 --- a/lib/l10n/gen/app_localizations_fr.dart +++ b/lib/l10n/gen/app_localizations_fr.dart @@ -1145,6 +1145,15 @@ class AppLocalizationsFr extends AppLocalizations { @override String get newVacationRangeTitle => 'Nouvelle période de vacances'; + @override + String get editVacationRangeTitle => 'Modifier la période de vacances'; + + @override + String get vacationDeleteConfirmTitle => 'Supprimer la période de vacances ?'; + + @override + String get vacationDeleteConfirmMessage => 'Cette action est irréversible.'; + @override String get startField => 'Début'; diff --git a/lib/l10n/gen/app_localizations_hi.dart b/lib/l10n/gen/app_localizations_hi.dart index eae2adb..3655468 100644 --- a/lib/l10n/gen/app_localizations_hi.dart +++ b/lib/l10n/gen/app_localizations_hi.dart @@ -1134,6 +1134,15 @@ class AppLocalizationsHi extends AppLocalizations { @override String get newVacationRangeTitle => 'नई छुट्टी अवधि'; + @override + String get editVacationRangeTitle => 'छुट्टी अवधि संपादित करें'; + + @override + String get vacationDeleteConfirmTitle => 'छुट्टी अवधि हटाएं?'; + + @override + String get vacationDeleteConfirmMessage => 'इसे वापस नहीं लिया जा सकता।'; + @override String get startField => 'शुरुआत'; diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index 430f546..a374b37 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -1139,6 +1139,16 @@ class AppLocalizationsId extends AppLocalizations { @override String get newVacationRangeTitle => 'Rentang liburan baru'; + @override + String get editVacationRangeTitle => 'Edit rentang liburan'; + + @override + String get vacationDeleteConfirmTitle => 'Hapus rentang liburan?'; + + @override + String get vacationDeleteConfirmMessage => + 'Tindakan ini tidak dapat dibatalkan.'; + @override String get startField => 'Mulai'; diff --git a/lib/l10n/gen/app_localizations_it.dart b/lib/l10n/gen/app_localizations_it.dart index 16ba528..d1c3a1d 100644 --- a/lib/l10n/gen/app_localizations_it.dart +++ b/lib/l10n/gen/app_localizations_it.dart @@ -1144,6 +1144,16 @@ class AppLocalizationsIt extends AppLocalizations { @override String get newVacationRangeTitle => 'Nuovo periodo di vacanza'; + @override + String get editVacationRangeTitle => 'Modifica periodo di vacanza'; + + @override + String get vacationDeleteConfirmTitle => 'Eliminare il periodo di vacanza?'; + + @override + String get vacationDeleteConfirmMessage => + 'Questa azione non può essere annullata.'; + @override String get startField => 'Inizio'; diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 7fb19ca..1a01bd9 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -1101,6 +1101,15 @@ class AppLocalizationsJa extends AppLocalizations { @override String get newVacationRangeTitle => '新しい休暇期間'; + @override + String get editVacationRangeTitle => '休暇期間を編集'; + + @override + String get vacationDeleteConfirmTitle => '休暇期間を削除しますか?'; + + @override + String get vacationDeleteConfirmMessage => 'この操作は元に戻せません。'; + @override String get startField => '開始'; diff --git a/lib/l10n/gen/app_localizations_pt.dart b/lib/l10n/gen/app_localizations_pt.dart index b49f45c..d6c92e1 100644 --- a/lib/l10n/gen/app_localizations_pt.dart +++ b/lib/l10n/gen/app_localizations_pt.dart @@ -1138,6 +1138,15 @@ class AppLocalizationsPt extends AppLocalizations { @override String get newVacationRangeTitle => 'Novo período de férias'; + @override + String get editVacationRangeTitle => 'Editar período de férias'; + + @override + String get vacationDeleteConfirmTitle => 'Excluir período de férias?'; + + @override + String get vacationDeleteConfirmMessage => 'Esta ação não pode ser desfeita.'; + @override String get startField => 'Início'; diff --git a/lib/l10n/gen/app_localizations_ru.dart b/lib/l10n/gen/app_localizations_ru.dart index 919297f..01054c2 100644 --- a/lib/l10n/gen/app_localizations_ru.dart +++ b/lib/l10n/gen/app_localizations_ru.dart @@ -1140,6 +1140,15 @@ class AppLocalizationsRu extends AppLocalizations { @override String get newVacationRangeTitle => 'Новый период отпуска'; + @override + String get editVacationRangeTitle => 'Изменить период отпуска'; + + @override + String get vacationDeleteConfirmTitle => 'Удалить период отпуска?'; + + @override + String get vacationDeleteConfirmMessage => 'Это действие нельзя отменить.'; + @override String get startField => 'Начало'; diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index 55a8635..fc7826b 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -1097,6 +1097,15 @@ class AppLocalizationsZh extends AppLocalizations { @override String get newVacationRangeTitle => '新的假期范围'; + @override + String get editVacationRangeTitle => '编辑假期范围'; + + @override + String get vacationDeleteConfirmTitle => '删除假期范围?'; + + @override + String get vacationDeleteConfirmMessage => '此操作无法撤销。'; + @override String get startField => '开始'; diff --git a/lib/pantallas/pantalla_vacaciones.dart b/lib/pantallas/pantalla_vacaciones.dart index 3acb755..3fb2d97 100644 --- a/lib/pantallas/pantalla_vacaciones.dart +++ b/lib/pantallas/pantalla_vacaciones.dart @@ -82,13 +82,78 @@ class PantallaVacaciones extends StatelessWidget { ); } - Future _abrirAlta(BuildContext context) async { - await showModalBottomSheet( - context: context, - isScrollControlled: true, - useSafeArea: true, - backgroundColor: Colors.transparent, - builder: (_) => const _EditorVacacionesSheet(), + Future _abrirAlta(BuildContext context) => + _abrirEditorVacaciones(context); +} + +/// Issue 1 (feedback-pruebas): the ONE sheet-opener both the header/CTA +/// "create" entry points and every range's own "tap to edit" affordance call +/// -- passing [rango] switches the sheet from create to edit mode (mirrors +/// `pantalla_alarmas.dart`'s `_abrirEditor`/`_EditorAlarmaSheet` split). +Future _abrirEditorVacaciones( + BuildContext context, { + RangoVacaciones? rango, +}) async { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + backgroundColor: Colors.transparent, + builder: (_) => _EditorVacacionesSheet(rango: rango), + ); +} + +/// Issue 1 (feedback-pruebas): mirrors `pantalla_alarmas.dart`'s +/// `_confirmarEliminarAlarma` exactly -- same AlertDialog shape, same +/// generic delete/cancel actions, only the copy is vacation-specific. +Future _confirmarEliminarRango( + BuildContext context, + AppLocalizations l10n, +) async { + final confirmado = await showDialog( + context: context, + builder: + (ctx) => AlertDialog( + title: Text(l10n.vacationDeleteConfirmTitle), + content: Text(l10n.vacationDeleteConfirmMessage), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(l10n.cancelAction), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text(l10n.deleteAction), + ), + ], + ), + ); + return confirmado ?? false; +} + +/// Swipe-to-delete reveal shown on both sides, mirroring +/// `pantalla_alarmas.dart`'s `_FondoSwipeEliminarAlarma` -- duplicated +/// rather than shared, matching this codebase's own precedent for tiny +/// per-screen chrome (see this file's `_DashedBorderPainter` doc comment). +class _FondoSwipeEliminarRango extends StatelessWidget { + const _FondoSwipeEliminarRango({required this.alignment}); + + final Alignment alignment; + + @override + Widget build(BuildContext context) { + final tokens = context.pluriTokens; + return Container( + alignment: alignment, + padding: const EdgeInsets.symmetric(horizontal: 24), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.error, + borderRadius: BorderRadius.circular(tokens.radiusMd), + ), + child: Icon( + Icons.delete_outline_rounded, + color: Theme.of(context).colorScheme.onError, + ), ); } } @@ -242,46 +307,81 @@ class _HeroRangoActivo extends StatelessWidget { final diasRestantes = rango.finDia.difference(hoyDia).inDays; final impacto = estado.impactoDeRango(rango); final type = context.pluriType; + final tokens = context.pluriTokens; - return PluriGlassSurface( - glowColor: context.pluriTokens.electricMagenta.withValues(alpha: 0.24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - localizedVacationName(l10n, rango.nombre), - style: Theme.of( - context, - ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w900), - ), - const SizedBox(height: 4), - // Item 21 / audit 9b.4 (t4:454): the "active now" caption is a - // teal eyebrow, not default body text. - Text( - l10n.vacationSummaryActiveCountdown(diasRestantes), - style: type.eyebrowLabel.copyWith(color: PluriWaveTokens.brand), - ), - const SizedBox(height: 12), - // Item 21 / audit 9b.4 (t4:451-462): the screen's signature - // element is a start/end date pair joined by a gradient rule — - // the prototype never draws a determinate progress bar here. - _ParFechasVacaciones( - inicio: rango.inicioDia, - fin: rango.finDia, - destacado: true, - reglaKey: const ValueKey('vacaciones-regla-activo'), - ), - if (impacto.pausadas.isNotEmpty) ...[ - const SizedBox(height: 12), - Text(l10n.vacationImpactPausedLabel(_horas(impacto.pausadas))), - ], - if (impacto.noAfectadas.isNotEmpty) ...[ - const SizedBox(height: 4), - Text( - l10n.vacationImpactContinuesLabel(_horas(impacto.noAfectadas)), + // Issue 1 (feedback-pruebas): a range starts ACTIVE the instant it's + // created (today .. today+2), so this hero is the ONLY place a + // brand-new range ever renders until it either becomes "programado" in + // the future or "pasado" once it ends. Without tap/swipe here, the + // very first range a user creates could never be fixed or removed. + return Dismissible( + key: ValueKey('vacaciones-tarjeta-${rango.id}'), + direction: DismissDirection.horizontal, + background: const _FondoSwipeEliminarRango( + alignment: Alignment.centerLeft, + ), + secondaryBackground: const _FondoSwipeEliminarRango( + alignment: Alignment.centerRight, + ), + confirmDismiss: (_) => _confirmarEliminarRango(context, l10n), + onDismissed: (_) => estado.eliminarRangoVacaciones(rango.id), + child: PluriGlassSurface( + glowColor: tokens.electricMagenta.withValues(alpha: 0.24), + padding: EdgeInsets.zero, + child: Material( + type: MaterialType.transparency, + child: InkWell( + borderRadius: BorderRadius.circular(tokens.radiusMd), + onTap: () => _abrirEditorVacaciones(context, rango: rango), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + localizedVacationName(l10n, rango.nombre), + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(height: 4), + // Item 21 / audit 9b.4 (t4:454): the "active now" caption is a + // teal eyebrow, not default body text. + Text( + l10n.vacationSummaryActiveCountdown(diasRestantes), + style: type.eyebrowLabel.copyWith( + color: PluriWaveTokens.brand, + ), + ), + const SizedBox(height: 12), + // Item 21 / audit 9b.4 (t4:451-462): the screen's signature + // element is a start/end date pair joined by a gradient rule — + // the prototype never draws a determinate progress bar here. + _ParFechasVacaciones( + inicio: rango.inicioDia, + fin: rango.finDia, + destacado: true, + reglaKey: const ValueKey('vacaciones-regla-activo'), + ), + if (impacto.pausadas.isNotEmpty) ...[ + const SizedBox(height: 12), + Text( + l10n.vacationImpactPausedLabel(_horas(impacto.pausadas)), + ), + ], + if (impacto.noAfectadas.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + l10n.vacationImpactContinuesLabel( + _horas(impacto.noAfectadas), + ), + ), + ], + ], + ), ), - ], - ], + ), + ), ), ); } @@ -452,61 +552,87 @@ class _TarjetaRangoVacaciones extends StatelessWidget { final l10n = AppLocalizations.of(context); final t = context.pluriTokens; final type = context.pluriType; - return DecoratedBox( - decoration: BoxDecoration( - color: t.listSurface, - borderRadius: BorderRadius.circular(20), - border: Border.all(color: Colors.white.withValues(alpha: 0.08)), + final estado = context.read(); + // Issue 1 (feedback-pruebas): tap = edit, swipe = delete (with + // confirmation) — same interaction `pantalla_alarmas.dart`'s + // `_TarjetaAlarma` already uses for the same concept, applied here to + // BOTH the "programados" and "pasados" sections (this card backs both). + return Dismissible( + key: ValueKey('vacaciones-tarjeta-${rango.id}'), + direction: DismissDirection.horizontal, + background: const _FondoSwipeEliminarRango( + alignment: Alignment.centerLeft, ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (encabezado != null) ...[ - Text( - encabezado!, - style: type.eyebrowLabel.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.5), - ), - ), - const SizedBox(height: 11), - ], - _ParFechasVacaciones( - inicio: rango.inicioDia, - fin: rango.finDia, - destacado: false, - reglaKey: ValueKey('vacaciones-regla-${rango.id}'), - ), - const SizedBox(height: 12), - Row( - children: [ - Icon( - Icons.label_outline_rounded, - size: 17, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.6), - ), - const SizedBox(width: 8), - Expanded( - child: Text( - localizedVacationName(l10n, rango.nombre), - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w600, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.6), + secondaryBackground: const _FondoSwipeEliminarRango( + alignment: Alignment.centerRight, + ), + confirmDismiss: (_) => _confirmarEliminarRango(context, l10n), + onDismissed: (_) => estado.eliminarRangoVacaciones(rango.id), + child: DecoratedBox( + decoration: BoxDecoration( + color: t.listSurface, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: Colors.white.withValues(alpha: 0.08)), + ), + child: Material( + type: MaterialType.transparency, + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: () => _abrirEditorVacaciones(context, rango: rango), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (encabezado != null) ...[ + Text( + encabezado!, + style: type.eyebrowLabel.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.5), + ), ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + const SizedBox(height: 11), + ], + _ParFechasVacaciones( + inicio: rango.inicioDia, + fin: rango.finDia, + destacado: false, + reglaKey: ValueKey('vacaciones-regla-${rango.id}'), ), - ), - ], + const SizedBox(height: 12), + Row( + children: [ + Icon( + Icons.label_outline_rounded, + size: 17, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.6), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + localizedVacationName(l10n, rango.nombre), + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.6), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + ), ), - ], + ), ), ), ); @@ -609,11 +735,17 @@ class _BloqueFecha extends StatelessWidget { } } -/// Add-range form. Moved verbatim from `pantalla_alarmas.dart` (WU8's +/// Add/edit-range form. Moved verbatim from `pantalla_alarmas.dart` (WU8's /// `_PantallaVacacionesTemporal` used it as a placeholder push target; now -/// this screen is the one real consumer). Behaviour unchanged. +/// this screen is the one real consumer). Create behaviour unchanged; issue +/// 1 (feedback-pruebas) adds the edit half via the optional [rango] — the +/// SAME sheet, mirroring `pantalla_alarmas.dart`'s `_EditorAlarmaSheet` +/// (`alarma == null` -> create, non-null -> edit; one shared save button +/// either way). class _EditorVacacionesSheet extends StatefulWidget { - const _EditorVacacionesSheet(); + const _EditorVacacionesSheet({this.rango}); + + final RangoVacaciones? rango; @override State<_EditorVacacionesSheet> createState() => _EditorVacacionesSheetState(); @@ -629,16 +761,29 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> { @override void initState() { super.initState(); - final hoy = DateTime.now(); - _inicio = DateTime(hoy.year, hoy.month, hoy.day); - _fin = _inicio.add(const Duration(days: 2)); + final rango = widget.rango; + if (rango != null) { + _inicio = rango.inicioDia; + _fin = rango.finDia; + } else { + final hoy = DateTime.now(); + _inicio = DateTime(hoy.year, hoy.month, hoy.day); + _fin = _inicio.add(const Duration(days: 2)); + } } @override void didChangeDependencies() { super.didChangeDependencies(); + final rango = widget.rango; _nombreController ??= TextEditingController( - text: AppLocalizations.of(context).vacationsDefaultName, + text: + rango != null + ? localizedVacationName( + AppLocalizations.of(context), + rango.nombre, + ) + : AppLocalizations.of(context).vacationsDefaultName, ); } @@ -662,7 +807,9 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - l10n.newVacationRangeTitle, + widget.rango != null + ? l10n.editVacationRangeTitle + : l10n.newVacationRangeTitle, style: Theme.of( context, ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w900), @@ -709,10 +856,16 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> { Future _elegirFecha({required bool esInicio}) async { final actual = esInicio ? _inicio : _fin; final hoy = DateTime.now(); + final hoyDia = DateTime(hoy.year, hoy.month, hoy.day); + // Issue 1 (feedback-pruebas): editing a PAST range (reachable from the + // "Rangos pasados" section) must not force its dates into the future — + // `firstDate` only floors at today for a range that starts there or + // later; an already-past range keeps its own start as the floor. + final primerDiaPermitido = _inicio.isBefore(hoyDia) ? _inicio : hoyDia; final seleccion = await showDatePicker( context: context, initialDate: actual, - firstDate: DateTime(hoy.year, hoy.month, hoy.day), + firstDate: primerDiaPermitido, lastDate: hoy.add(const Duration(days: 1460)), ); if (seleccion == null) return; @@ -728,12 +881,26 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> { Future _guardar() async { final estado = context.read(); - final rango = estado.servicio.crearRangoVacaciones( - inicio: _inicio, - fin: _fin, - nombre: _nombreController?.text.trim() ?? '', - ); - await estado.crearRangoVacaciones(rango); + final nombre = _nombreController?.text.trim() ?? ''; + final existente = widget.rango; + if (existente != null) { + await estado.editarRangoVacaciones( + RangoVacaciones( + id: existente.id, + nombre: nombre, + inicio: _inicio, + fin: _fin, + activo: existente.activo, + ), + ); + } else { + final rango = estado.servicio.crearRangoVacaciones( + inicio: _inicio, + fin: _fin, + nombre: nombre, + ); + await estado.crearRangoVacaciones(rango); + } if (mounted) Navigator.pop(context); } } diff --git a/test/pantallas/pantalla_vacaciones_test.dart b/test/pantallas/pantalla_vacaciones_test.dart index 539bcf6..f0e15bd 100644 --- a/test/pantallas/pantalla_vacaciones_test.dart +++ b/test/pantallas/pantalla_vacaciones_test.dart @@ -445,4 +445,173 @@ void main() { }, ); }); + + group('issue 1 (feedback-pruebas): editar y eliminar rangos', () { + testWidgets( + 'tocar la tarjeta de un rango programado abre el editor precargado ' + 'con su nombre y fechas', + (tester) async { + final estado = await _crearEstado( + vacaciones: [ + RangoVacaciones( + id: 'f2', + nombre: 'Verano', + inicio: _hoyDia.add(const Duration(days: 20)), + fin: _hoyDia.add(const Duration(days: 25)), + ), + ], + ); + addTearDown(estado.dispose); + + await tester.pumpWidget(_buildScreen(estado)); + await _pumpEstable(tester); + final l10n = AppLocalizations.of( + tester.element(find.byType(PantallaVacaciones)), + ); + + await tester.tap(find.byKey(const ValueKey('vacaciones-tarjeta-f2'))); + await _pumpEstable(tester); + + expect(find.text(l10n.editVacationRangeTitle), findsOneWidget); + // Scoped to the TextField specifically -- the original card's OWN + // "Verano" label is still (offstage, behind the modal) in the tree, + // so a bare `find.text('Verano')` would ambiguously match both. + expect(find.widgetWithText(TextField, 'Verano'), findsOneWidget); + }, + ); + + testWidgets( + 'guardar el editor abierto por tap actualiza el rango existente (no ' + 'crea uno nuevo)', + (tester) async { + final estado = await _crearEstado( + vacaciones: [ + RangoVacaciones( + id: 'f2', + nombre: 'Verano', + inicio: _hoyDia.add(const Duration(days: 20)), + fin: _hoyDia.add(const Duration(days: 25)), + ), + ], + ); + addTearDown(estado.dispose); + + await tester.pumpWidget(_buildScreen(estado)); + await _pumpEstable(tester); + final l10n = AppLocalizations.of( + tester.element(find.byType(PantallaVacaciones)), + ); + + await tester.tap(find.byKey(const ValueKey('vacaciones-tarjeta-f2'))); + await _pumpEstable(tester); + + await tester.enterText(find.byType(TextField), 'Verano renombrado'); + final boton = tester.widget( + find.widgetWithText(FilledButton, l10n.saveRangeAction), + ); + boton.onPressed!(); + await _pumpEstable(tester); + + expect(estado.vacaciones, hasLength(1)); + expect(estado.vacaciones.single.id, 'f2'); + expect(estado.vacaciones.single.nombre, 'Verano renombrado'); + }, + ); + + testWidgets( + 'deslizar la tarjeta de un rango pide confirmacion; cancelar la ' + 'conserva y confirmar la elimina', + (tester) async { + final estado = await _crearEstado( + vacaciones: [ + RangoVacaciones( + id: 'f2', + nombre: 'Verano', + inicio: _hoyDia.add(const Duration(days: 20)), + fin: _hoyDia.add(const Duration(days: 25)), + ), + ], + ); + addTearDown(estado.dispose); + + await tester.pumpWidget(_buildScreen(estado)); + await _pumpEstable(tester); + final l10n = AppLocalizations.of( + tester.element(find.byType(PantallaVacaciones)), + ); + + // Cancelar: el rango se conserva. + await tester.drag( + find.byKey(const ValueKey('vacaciones-tarjeta-f2')), + const Offset(-600, 0), + ); + await _pumpEstable(tester); + expect(find.text(l10n.vacationDeleteConfirmTitle), findsOneWidget); + await tester.tap(find.text(l10n.cancelAction)); + await _pumpEstable(tester); + expect(estado.vacaciones, hasLength(1)); + + // Confirmar: el rango se elimina. + await tester.drag( + find.byKey(const ValueKey('vacaciones-tarjeta-f2')), + const Offset(-600, 0), + ); + await _pumpEstable(tester); + expect(find.text(l10n.vacationDeleteConfirmTitle), findsOneWidget); + await tester.tap(find.text(l10n.deleteAction)); + await _pumpEstable(tester); + + expect(estado.vacaciones, isEmpty); + }, + ); + + testWidgets( + 'el rango ACTIVO (mostrado en el hero) tambien se puede editar (tap) ' + 'y eliminar (swipe) -- un rango recien creado siempre esta activo y ' + 'nunca aparece en las listas programado/pasado', + (tester) async { + final estado = await _crearEstado( + vacaciones: [ + RangoVacaciones( + id: 'v1', + nombre: 'Julio activo', + inicio: _hoyDia.subtract(const Duration(days: 3)), + fin: _hoyDia.add(const Duration(days: 5)), + ), + ], + ); + addTearDown(estado.dispose); + + await tester.pumpWidget(_buildScreen(estado)); + await _pumpEstable(tester); + final l10n = AppLocalizations.of( + tester.element(find.byType(PantallaVacaciones)), + ); + + await tester.tap(find.byKey(const ValueKey('vacaciones-tarjeta-v1'))); + await _pumpEstable(tester); + expect(find.text(l10n.editVacationRangeTitle), findsOneWidget); + // Scoped to the TextField specifically -- the hero's OWN "Julio + // activo" label is still (offstage, behind the modal) in the tree. + expect(find.widgetWithText(TextField, 'Julio activo'), findsOneWidget); + // Dismiss the editor sheet (no explicit close button -- same as the + // pre-existing "Anadir rango" sheet, dismissible via the standard + // modal-bottom-sheet Navigator.pop) before interacting with the + // list underneath it. + Navigator.of(tester.element(find.byType(PantallaVacaciones))).pop(); + await _pumpEstable(tester); + + await tester.drag( + find.byKey(const ValueKey('vacaciones-tarjeta-v1')), + const Offset(-600, 0), + ); + await _pumpEstable(tester); + await tester.tap(find.text(l10n.deleteAction)); + await _pumpEstable(tester); + + expect(estado.vacaciones, isEmpty); + expect(find.text(l10n.vacationNoActiveRangeHint), findsOneWidget); + }, + ); + }); } From e75f010b98dec1254fd4c92a1285eb1bcd86900a Mon Sep 17 00:00:00 2001 From: freetlab Date: Thu, 30 Jul 2026 19:08:06 +0200 Subject: [PATCH 4/7] fix(ajustes): stop settings row titles from wrapping and cutting off FilaAjuste's title Text had no maxLines/overflow, and neither did its trailing current-value Text. An unbounded value (e.g. a real station name in "Emisora preferida") let the trailing Row claim unbounded width, squeezing the title down until it wrapped across several lines that the row's fixed height then cut short. Constrain the title to a single ellipsized line and cap the trailing value's width the same way. FilaAjuste backs all 12 settings rows, so every row is protected, not just the one that happened to expose it. --- .../ajustes/widgets/fila_ajuste.dart | 37 ++++++++-- .../ajustes/widgets/fila_ajuste_test.dart | 69 +++++++++++++++++++ 2 files changed, 99 insertions(+), 7 deletions(-) diff --git a/lib/pantallas/ajustes/widgets/fila_ajuste.dart b/lib/pantallas/ajustes/widgets/fila_ajuste.dart index 75f52bf..964a629 100644 --- a/lib/pantallas/ajustes/widgets/fila_ajuste.dart +++ b/lib/pantallas/ajustes/widgets/fila_ajuste.dart @@ -93,6 +93,14 @@ class FilaAjuste extends StatelessWidget { /// "no accent" — the icon renders exactly as before. final Color? iconColor; + /// Issue 5 (feedback-pruebas): caps how much width the trailing current + /// value can claim. `ListTile` gives `trailing` as much width as it wants + /// before handing the title whatever is left — an unbounded value (e.g. a + /// real, arbitrarily long station name for "Emisora preferida") could + /// squeeze the title down to almost nothing, forcing it to wrap across + /// several lines that then get cut short by the row's fixed height. + static const _anchoMaximoValor = 108.0; + @override Widget build(BuildContext context) { final type = context.pluriType; @@ -106,17 +114,32 @@ class FilaAjuste extends StatelessWidget { // 14px/w700 (t4 line 514); cardTitle is 14.5/w700 — a one-off // override, not a new PluriWaveTypography style (mirrors the // precedent set for the ringing screen's station name, audit 9.7). - title: Text(titulo, style: type.cardTitle.copyWith(fontSize: 14)), + // Issue 5: constrained to one line, ellipsizing instead of wrapping — + // labels must wrap as little as possible and never render visibly + // truncated (a multi-line wrap inside this fixed-height row cuts the + // last line short, which reads as broken, not as intentional). + title: Text( + titulo, + style: type.cardTitle.copyWith(fontSize: 14), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ if (valorActual != null) ...[ - Text( - valorActual, - // bodyStrong is already 13/w600, matching the prototype's row - // value spec exactly — only the colour needs overriding. - style: type.bodyStrong.copyWith( - color: const Color(0xFFF2F7FA).withValues(alpha: 0.55), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: _anchoMaximoValor), + child: Text( + valorActual, + // bodyStrong is already 13/w600, matching the prototype's row + // value spec exactly — only the colour needs overriding. + style: type.bodyStrong.copyWith( + color: const Color(0xFFF2F7FA).withValues(alpha: 0.55), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, ), ), const SizedBox(width: 6), diff --git a/test/pantallas/ajustes/widgets/fila_ajuste_test.dart b/test/pantallas/ajustes/widgets/fila_ajuste_test.dart index 61a34b8..ae328a0 100644 --- a/test/pantallas/ajustes/widgets/fila_ajuste_test.dart +++ b/test/pantallas/ajustes/widgets/fila_ajuste_test.dart @@ -98,6 +98,75 @@ void main() { }, ); + testWidgets( + 'issue 5 (feedback-pruebas): a long trailing value does not squeeze the ' + 'title into wrapping across multiple lines -- the title stays on ONE ' + 'line, ellipsizing instead', + (tester) async { + const titulo = 'Emisora preferida'; + const valorLargo = 'Radio Nacional Clasica Internacional FM Stereo HD'; + + await tester.pumpWidget( + host( + SizedBox( + width: 360, + child: FilaAjuste( + icon: Icons.radio_rounded, + titulo: titulo, + valor: valorLargo, + onTap: () {}, + ), + ), + ), + ); + await tester.pump(); + + expect( + tester.takeException(), + isNull, + reason: 'a squeezed row must not overflow either', + ); + + final tituloWidget = tester.widget(find.text(titulo)); + expect( + tituloWidget.maxLines, + 1, + reason: 'issue 5: the title must be constrained to a single line', + ); + expect(tituloWidget.overflow, TextOverflow.ellipsis); + + // Measured, not just `find.text` (a wrapped-but-still-present Text + // would still satisfy a bare `find.text` match, per the known + // "find.text can't catch visual wrap" trap) -- compare the rendered + // height against the SAME style/width rendered with a title that is + // guaranteed to fit on one line. + final alturaConValorLargo = tester.getSize(find.text(titulo)).height; + + await tester.pumpWidget( + host( + SizedBox( + width: 360, + child: FilaAjuste( + icon: Icons.radio_rounded, + titulo: titulo, + onTap: () {}, + ), + ), + ), + ); + await tester.pump(); + final alturaReferencia = tester.getSize(find.text(titulo)).height; + + expect( + alturaConValorLargo, + closeTo(alturaReferencia, 1.0), + reason: + 'the title rendered taller with a long value present -- it ' + 'wrapped instead of staying on a single ellipsized line', + ); + }, + ); + testWidgets( 'visual fidelity (audit S10): GrupoAjustes insets its row divider by ' '47px, not full-bleed (t4 line 516)', From c6ab295c54e40b3bcf52b9ec80bb2f271d91009d Mon Sep 17 00:00:00 2001 From: freetlab Date: Thu, 30 Jul 2026 19:14:15 +0200 Subject: [PATCH 5/7] fix(timer): show the live countdown in the sleep timer sheet showPluriSleepTimerSheet already had a working countdown branch (ServicioTimer.tiempoRestanteStream), but every preset and the custom duration flow popped the sheet immediately after starting the timer -- so the countdown never rendered in the primary flow, only if the user happened to reopen the sheet afterwards. Stop popping the sheet on start; the existing Consumer already reacts to iniciarTimerDuracion's notifyListeners and swaps to the countdown view live. Also make the sheet scroll-controlled: at a realistic phone width the countdown's title + description + headline- sized remaining-time text overflowed the default half-screen cap that never mattered while the sheet always closed before that view could render. --- lib/widgets/pluri_sleep_timer_sheet.dart | 27 ++- .../widgets/pluri_sleep_timer_sheet_test.dart | 184 ++++++++++++++++++ 2 files changed, 204 insertions(+), 7 deletions(-) create mode 100644 test/widgets/pluri_sleep_timer_sheet_test.dart diff --git a/lib/widgets/pluri_sleep_timer_sheet.dart b/lib/widgets/pluri_sleep_timer_sheet.dart index 577a466..44c6fdb 100644 --- a/lib/widgets/pluri_sleep_timer_sheet.dart +++ b/lib/widgets/pluri_sleep_timer_sheet.dart @@ -14,6 +14,13 @@ import 'pluri_layout.dart'; void showPluriSleepTimerSheet(BuildContext context) { showModalBottomSheet( context: context, + // Issue 2 (feedback-pruebas): without this, the sheet is capped to a + // FRACTION of the screen height. That never mattered while the sheet + // always closed immediately after picking a duration (see the removed + // `Navigator.pop` calls below) -- now that the countdown view actually + // stays open, its title + description + headline-sized remaining-time + // text can overflow that capped height on a real phone width. + isScrollControlled: true, showDragHandle: true, builder: (ctx) => Consumer( @@ -79,12 +86,17 @@ void showPluriSleepTimerSheet(BuildContext context) { Duration(seconds: segundos), ), ), - onPressed: () { - estado.iniciarTimerDuracion( - Duration(seconds: segundos), - ); - Navigator.pop(ctx); - }, + // Issue 2 (feedback-pruebas): no longer pops + // the sheet -- `estado.iniciarTimerDuracion` + // notifies this `Consumer`, + // which swaps straight to the countdown + // view above so the user actually SEES the + // remaining time instead of the sheet just + // closing with no feedback. + onPressed: + () => estado.iniciarTimerDuracion( + Duration(seconds: segundos), + ), ), ActionChip( avatar: const Icon(Icons.tune_rounded, size: 18), @@ -93,8 +105,9 @@ void showPluriSleepTimerSheet(BuildContext context) { final duracion = await _pedirDuracionPersonalizada(ctx); if (duracion == null || !ctx.mounted) return; + // Issue 2: same as above -- stays open on + // the countdown view rather than closing. estado.iniciarTimerDuracion(duracion); - Navigator.pop(ctx); }, ), ], diff --git a/test/widgets/pluri_sleep_timer_sheet_test.dart b/test/widgets/pluri_sleep_timer_sheet_test.dart new file mode 100644 index 0000000..8f0bbcd --- /dev/null +++ b/test/widgets/pluri_sleep_timer_sheet_test.dart @@ -0,0 +1,184 @@ +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/widgets/pluri_sleep_timer_sheet.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../helpers/fakes.dart'; +import '../helpers/fakes_alarmas.dart'; + +/// Issue 2 (feedback-pruebas): "Timer de sueño" opened from Escuchar (and +/// every other root header) showed a bottom sheet with no visible +/// countdown. `ServicioTimer.tiempoRestanteStream`/`tiempoRestante` already +/// existed and this sheet already had a `StreamBuilder` countdown branch — +/// but every preset/custom-duration action popped the sheet immediately +/// after starting the timer, so the countdown never had a chance to render +/// in the primary flow. Zero test coverage existed for this file before. +EstadoRadio _estado() => EstadoRadio( + audio: FakeServicioAudio(), + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + servicioGrabacion: FakeServicioGrabacionRadioInactiva(), + iniciarAutomaticamente: false, +); + +Widget _host(EstadoRadio estado) { + return ChangeNotifierProvider.value( + value: estado, + child: MaterialApp( + locale: const Locale('es'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Builder( + builder: + (context) => TextButton( + onPressed: () => showPluriSleepTimerSheet(context), + child: const Text('abrir'), + ), + ), + ), + ), + ); +} + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + // The default 800x600 test viewport is shorter than a typical phone — + // same fix `pantalla_reproductor_test.dart`/`pantalla_inicio_test.dart` + // already established for other bottom-sheet/full-bleed content. + void ajustarSuperficieRealista(WidgetTester tester) { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + } + + testWidgets( + 'selecting a preset keeps the sheet open and switches it to the live ' + 'remaining-time countdown, instead of closing with no feedback', + (tester) async { + ajustarSuperficieRealista(tester); + final estado = _estado(); + addTearDown(estado.dispose); + + await tester.pumpWidget(_host(estado)); + await tester.tap(find.text('abrir')); + await tester.pumpAndSettle(); + + final l10n = AppLocalizations.of(tester.element(find.text('abrir'))); + + expect(estado.timer.activo, isFalse); + expect(find.byType(ActionChip), findsWidgets); + + final chip = tester.widget( + find.widgetWithText(ActionChip, l10n.durationMinutesOnly(5)), + ); + chip.onPressed!(); + await tester.pump(); + + expect(estado.timer.activo, isTrue); + expect( + find.byType(ActionChip), + findsNothing, + reason: 'issue 2: the picker is replaced by the countdown view', + ); + expect(find.text(l10n.cancelTimer), findsOneWidget); + expect( + find.text(l10n.durationMinutesOnly(5)), + findsOneWidget, + reason: 'issue 2: the remaining time is now surfaced live', + ); + + // `ServicioTimer` starts a REAL periodic Timer -- `addTearDown` + // callbacks run too late to satisfy the "no pending timers" check + // (a well-known flutter_test ordering quirk), so it must be + // cancelled here, inside the test body, before it ends. + await estado.timer.cancelar(); + }, + ); + + testWidgets('reopening the sheet while a timer is already active shows the ' + 'countdown immediately, not the duration picker', (tester) async { + ajustarSuperficieRealista(tester); + final estado = _estado(); + addTearDown(estado.dispose); + estado.iniciarTimerDuracion(const Duration(minutes: 10)); + + await tester.pumpWidget(_host(estado)); + await tester.tap(find.text('abrir')); + await tester.pumpAndSettle(); + + final l10n = AppLocalizations.of(tester.element(find.text('abrir'))); + expect(find.byType(ActionChip), findsNothing); + expect(find.text(l10n.cancelTimer), findsOneWidget); + expect(find.text(l10n.durationMinutesOnly(10)), findsOneWidget); + + // See the comment in the previous test — cancel before the body ends. + await estado.timer.cancelar(); + }); + + testWidgets( + 'cancelling from the countdown view stops the timer and closes the ' + 'sheet', + (tester) async { + ajustarSuperficieRealista(tester); + final estado = _estado(); + addTearDown(estado.dispose); + estado.iniciarTimerDuracion(const Duration(minutes: 10)); + + await tester.pumpWidget(_host(estado)); + await tester.tap(find.text('abrir')); + await tester.pumpAndSettle(); + + final l10n = AppLocalizations.of(tester.element(find.text('abrir'))); + final boton = tester.widget( + find.widgetWithText(FilledButton, l10n.cancelTimer), + ); + boton.onPressed!(); + await tester.pumpAndSettle(); + + expect(estado.timer.activo, isFalse); + expect(find.text(l10n.cancelTimer), findsNothing); + }, + ); + + testWidgets( + 'starting a custom duration ALSO keeps the sheet open on the countdown ' + 'view, not just the presets', + (tester) async { + ajustarSuperficieRealista(tester); + final estado = _estado(); + addTearDown(estado.dispose); + + await tester.pumpWidget(_host(estado)); + await tester.tap(find.text('abrir')); + await tester.pumpAndSettle(); + + final l10n = AppLocalizations.of(tester.element(find.text('abrir'))); + await tester.tap(find.text(l10n.optionOther)); + await tester.pumpAndSettle(); + + // Confirm the custom-duration sub-sheet directly (default prefilled + // value is already "15" minutes -- see _TimerPersonalizadoSheetState). + final confirmar = tester.widget( + find.widgetWithText(FilledButton, l10n.startTimer), + ); + confirmar.onPressed!(); + await tester.pumpAndSettle(); + + expect(estado.timer.activo, isTrue); + expect(find.byType(ActionChip), findsNothing); + expect(find.text(l10n.cancelTimer), findsOneWidget); + + // See the comment in the first test — cancel before the body ends. + await estado.timer.cancelar(); + }, + ); +} From 727e18737a4bce901533a71bc6b3aea8faa7fe57 Mon Sep 17 00:00:00 2001 From: freetlab Date: Thu, 30 Jul 2026 19:18:14 +0200 Subject: [PATCH 6/7] fix(radio): persist the last-played station across restarts EstadoRadio.emisoraActual only ever reflected in-memory state (_emisoraSeleccionada or the live audio service), so stopping playback and reopening the app left the Escuchar hero empty even though the user had a station selected right before closing it. Persist the station whenever it changes (reproducir(), and the Android-Auto out-of-band reconciliation path) and restore it as _emisoraSeleccionada on the next cold start, only when nothing is already selected. This never touches the audio service directly: no playback starts and estadoStream/estaSonando stay at their stopped default, matching how every consumer already gates "is it playing" on the playback-status stream rather than on emisoraActual itself. --- lib/estado/estado_radio.dart | 55 ++++ test/estado/estado_radio_test.dart | 513 ++++++++++++++++------------- 2 files changed, 339 insertions(+), 229 deletions(-) diff --git a/lib/estado/estado_radio.dart b/lib/estado/estado_radio.dart index 5d8a57f..5bf7e08 100644 --- a/lib/estado/estado_radio.dart +++ b/lib/estado/estado_radio.dart @@ -173,6 +173,10 @@ class EstadoRadio extends ChangeNotifier { static const _keyEmisoraPreferida = 'emisora_preferida_uuid_v1'; static const _keyOrdenListas = 'orden_listas_emisoras_v1'; static const _keyTimerSuenoPresets = 'timer_sueno_presets_segundos_v1'; + // Issue 4 (feedback-pruebas): last-played station, so the Escuchar hero + // keeps showing "what I was listening to" (stopped, not playing) after a + // full app restart instead of going empty. + static const _keyUltimaEmisora = 'ultima_emisora_v1'; static const _timerSuenoPresetsDefecto = [ 180, 300, @@ -300,6 +304,50 @@ class EstadoRadio extends ChangeNotifier { _cargarEmisorasCustom(), ]); await _normalizarEmisoraPreferida(); + await _restaurarUltimaEmisora(); + } + + /// Issue 4 (feedback-pruebas): restores the last-played station as a + /// STOPPED `emisoraActual` on a cold start. Only fills the gap — if + /// something is ALREADY selected (a real play already ran concurrently), + /// this is a no-op. Never touches `audio`: no playback starts, no network + /// request is made, `estadoStream`/`estaSonando` stay at their fresh + /// "detenido" default, exactly like every other consumer of + /// `emisoraActual` already expects (they gate "is it playing" on the + /// separate playback-status stream, never on `emisoraActual != null`). + Future _restaurarUltimaEmisora() async { + if (_emisoraSeleccionada != null || audio.emisoraActual != null) return; + try { + final prefs = await _resolverPrefs(); + final raw = prefs.getString(_keyUltimaEmisora); + if (raw == null) return; + final mapa = jsonDecode(raw) as Map; + _emisoraSeleccionada = Emisora.fromMap(mapa); + } catch (e) { + registrarSaltoPersistencia( + subsistema: 'ultima_emisora', + detalle: 'restaurar', + razon: e.toString(), + ); + } + } + + /// Best-effort remembers [emisora] as the last used station (issue 4) so + /// [_restaurarUltimaEmisora] can bring it back after a restart. Fire-and- + /// forget, same treatment [reproducir] already gives other non-critical + /// side effects (e.g. `radio.registrarClick`) — a failed write here must + /// never block or fail actual playback. + Future _persistirUltimaEmisora(Emisora emisora) async { + try { + final prefs = await _resolverPrefs(); + await prefs.setString(_keyUltimaEmisora, jsonEncode(emisora.toMap())); + } catch (e) { + registrarSaltoPersistencia( + subsistema: 'ultima_emisora', + detalle: 'persistir ${emisora.uuid}', + razon: e.toString(), + ); + } } /// Escucha el stream de estado del audio y gestiona errores de reproducción. @@ -321,6 +369,9 @@ class EstadoRadio extends ChangeNotifier { final actual = audio.emisoraActual; if (actual != null && actual.uuid != _emisoraSeleccionada?.uuid) { _emisoraSeleccionada = actual; + // Issue 4: an Android-Auto-initiated selection is a real station + // change too — remember it the same way `reproducir` does. + unawaited(_persistirUltimaEmisora(actual)); } notifyListeners(); }); @@ -508,6 +559,10 @@ class EstadoRadio extends ChangeNotifier { } _emisoraSeleccionada = emisora; notifyListeners(); + // Issue 4: remembers the station the user just picked so it survives a + // restart — fire-and-forget, same treatment as `radio.registrarClick` + // below (a persistence failure here must never block playback). + unawaited(_persistirUltimaEmisora(emisora)); try { await audio.reproducir(emisora); if (revision != _revisionReproduccion) return; diff --git a/test/estado/estado_radio_test.dart b/test/estado/estado_radio_test.dart index 96ffa72..f8d2f76 100644 --- a/test/estado/estado_radio_test.dart +++ b/test/estado/estado_radio_test.dart @@ -412,259 +412,314 @@ void main() { ); }); - group( - 'EstadoRadio — emisoras custom: lectura tolerante y guardia de ' - 'degradacion (persistence-resilience)', - () { - test( - 'entradas invalidas se omiten sin perder las validas ni fabricar ' - 'uuid (D5 parcial)', - () async { - final archivo = await _crearArchivoCustomRaw( - jsonEncode([ - {'uuid': 'custom-1', 'nombre': 'Valida Uno', 'url': 'http://a'}, - {'uuid': 'custom-2', 'nombre': 'Valida Dos', 'url': 'http://b'}, - // falta 'url' (campo requerido) -> Emisora.fromMap lanza. - {'uuid': 'custom-3', 'nombre': 'Sin url'}, - // falta 'uuid' -> Emisora.fromMap lanza. - {'nombre': 'Sin uuid', 'url': 'http://d'}, - ]), - ); - final estado = EstadoRadio( - audio: FakeServicioAudio(), - favoritos: FakeServicioFavoritos(), - radio: FakeServicioRadio(), - servicioEcualizador: FakeServicioEcualizador(), - resolverArchivoCustom: () async => archivo, - iniciarAutomaticamente: false, - ); - - await estado.inicializar(); - - expect(estado.emisorasCustom, hasLength(2)); - expect(estado.emisorasCustom.map((e) => e.uuid).toSet(), { - 'custom-1', - 'custom-2', - }); - }, + group('EstadoRadio — emisoras custom: lectura tolerante y guardia de ' + 'degradacion (persistence-resilience)', () { + test('entradas invalidas se omiten sin perder las validas ni fabricar ' + 'uuid (D5 parcial)', () async { + final archivo = await _crearArchivoCustomRaw( + jsonEncode([ + {'uuid': 'custom-1', 'nombre': 'Valida Uno', 'url': 'http://a'}, + {'uuid': 'custom-2', 'nombre': 'Valida Dos', 'url': 'http://b'}, + // falta 'url' (campo requerido) -> Emisora.fromMap lanza. + {'uuid': 'custom-3', 'nombre': 'Sin url'}, + // falta 'uuid' -> Emisora.fromMap lanza. + {'nombre': 'Sin uuid', 'url': 'http://d'}, + ]), + ); + final estado = EstadoRadio( + audio: FakeServicioAudio(), + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + resolverArchivoCustom: () async => archivo, + iniciarAutomaticamente: false, ); - test( - 'si resolver la ruta del archivo custom falla, la inicializacion ' - 'sobrevive y las cargas hermanas no se pierden (D5 IO-fail)', - () async { - final estado = EstadoRadio( - audio: FakeServicioAudio(), - favoritos: FakeServicioFavoritos(), - radio: FakeServicioRadio(), - servicioEcualizador: FakeServicioEcualizador(), - resolverArchivoCustom: - () async => throw const FileSystemException('sin storage'), - iniciarAutomaticamente: false, - ); + await estado.inicializar(); - // Path resolution failing must be treated as an IO-fail, not - // escape _cargarEmisorasCustom: it runs inside _init()'s - // Future.wait, so an uncaught throw would also reject the - // sibling loads (populares/favoritos/grupos). - await estado.inicializar(); + expect(estado.emisorasCustom, hasLength(2)); + expect(estado.emisorasCustom.map((e) => e.uuid).toSet(), { + 'custom-1', + 'custom-2', + }); + }); - expect(estado.emisorasCustom, isEmpty); - }, + test('si resolver la ruta del archivo custom falla, la inicializacion ' + 'sobrevive y las cargas hermanas no se pierden (D5 IO-fail)', () async { + final estado = EstadoRadio( + audio: FakeServicioAudio(), + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + resolverArchivoCustom: + () async => throw const FileSystemException('sin storage'), + iniciarAutomaticamente: false, ); - test( - 'JSON invalido al nivel superior pone en cuarentena el archivo ' - 'original (D5 parse-fail)', - () async { - final archivo = await _crearArchivoCustomRaw('{bad'); - final estado = EstadoRadio( - audio: FakeServicioAudio(), - favoritos: FakeServicioFavoritos(), - radio: FakeServicioRadio(), - servicioEcualizador: FakeServicioEcualizador(), - resolverArchivoCustom: () async => archivo, - iniciarAutomaticamente: false, - ); + // Path resolution failing must be treated as an IO-fail, not + // escape _cargarEmisorasCustom: it runs inside _init()'s + // Future.wait, so an uncaught throw would also reject the + // sibling loads (populares/favoritos/grupos). + await estado.inicializar(); - await estado.inicializar(); + expect(estado.emisorasCustom, isEmpty); + }); - expect(estado.emisorasCustom, isEmpty); - final sidecar = File('${archivo.path}.corrupt'); - expect(await sidecar.exists(), isTrue); - expect(await sidecar.readAsString(), '{bad'); - expect(await archivo.exists(), isFalse); - }, + test('JSON invalido al nivel superior pone en cuarentena el archivo ' + 'original (D5 parse-fail)', () async { + final archivo = await _crearArchivoCustomRaw('{bad'); + final estado = EstadoRadio( + audio: FakeServicioAudio(), + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + resolverArchivoCustom: () async => archivo, + iniciarAutomaticamente: false, ); - test( - 'agregar tras la cuarentena escribe solo la nueva emisora y no ' - 'toca el sidecar (D5, autoridad de escritura restaurada)', - () async { - final archivo = await _crearArchivoCustomRaw('{bad'); - final estado = EstadoRadio( - audio: FakeServicioAudio(), - favoritos: FakeServicioFavoritos(), - radio: FakeServicioRadio(), - servicioEcualizador: FakeServicioEcualizador(), - resolverArchivoCustom: () async => archivo, - iniciarAutomaticamente: false, - ); - await estado.inicializar(); - final sidecar = File('${archivo.path}.corrupt'); - final sidecarPrevio = await sidecar.readAsString(); + await estado.inicializar(); - final nueva = emisoraDemo(uuid: 'nueva-1', nombre: 'Nueva'); - await estado.agregarEmisoraCustom(nueva); + expect(estado.emisorasCustom, isEmpty); + final sidecar = File('${archivo.path}.corrupt'); + expect(await sidecar.exists(), isTrue); + expect(await sidecar.readAsString(), '{bad'); + expect(await archivo.exists(), isFalse); + }); - expect(estado.emisorasCustom.map((e) => e.uuid), ['nueva-1']); - final contenidoVivo = - jsonDecode(await archivo.readAsString()) as List; - expect(contenidoVivo, hasLength(1)); - expect((contenidoVivo.single as Map)['uuid'], 'nueva-1'); - expect(await sidecar.readAsString(), sidecarPrevio); - }, + test('agregar tras la cuarentena escribe solo la nueva emisora y no ' + 'toca el sidecar (D5, autoridad de escritura restaurada)', () async { + final archivo = await _crearArchivoCustomRaw('{bad'); + final estado = EstadoRadio( + audio: FakeServicioAudio(), + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + resolverArchivoCustom: () async => archivo, + iniciarAutomaticamente: false, + ); + await estado.inicializar(); + final sidecar = File('${archivo.path}.corrupt'); + final sidecarPrevio = await sidecar.readAsString(); + + final nueva = emisoraDemo(uuid: 'nueva-1', nombre: 'Nueva'); + await estado.agregarEmisoraCustom(nueva); + + expect(estado.emisorasCustom.map((e) => e.uuid), ['nueva-1']); + final contenidoVivo = jsonDecode(await archivo.readAsString()) as List; + expect(contenidoVivo, hasLength(1)); + expect((contenidoVivo.single as Map)['uuid'], 'nueva-1'); + expect(await sidecar.readAsString(), sidecarPrevio); + }); + + test('fallo de IO al leer suprime la escritura y no se restaura con un ' + 'alta explicita (D5 IO-fail)', () async { + final espia = _ArchivoEspia( + path: '/fake/emisoras_custom.json', + exists: () async => true, + readAsString: + () async => + throw const FileSystemException('fallo simulado de lectura'), + ); + final estado = EstadoRadio( + audio: FakeServicioAudio(), + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + resolverArchivoCustom: () async => espia, + iniciarAutomaticamente: false, ); - test( - 'fallo de IO al leer suprime la escritura y no se restaura con un ' - 'alta explicita (D5 IO-fail)', - () async { - final espia = _ArchivoEspia( - path: '/fake/emisoras_custom.json', - exists: () async => true, - readAsString: - () async => throw const FileSystemException( - 'fallo simulado de lectura', - ), - ); - final estado = EstadoRadio( - audio: FakeServicioAudio(), - favoritos: FakeServicioFavoritos(), - radio: FakeServicioRadio(), - servicioEcualizador: FakeServicioEcualizador(), - resolverArchivoCustom: () async => espia, - iniciarAutomaticamente: false, - ); + await estado.inicializar(); + expect(estado.emisorasCustom, isEmpty); - await estado.inicializar(); - expect(estado.emisorasCustom, isEmpty); - - await estado.agregarEmisoraCustom( - emisoraDemo(uuid: 'nueva-x', nombre: 'X'), - ); - - expect( - estado.emisorasCustom.map((e) => e.uuid), - contains('nueva-x'), - ); - expect(espia.writeAsStringCalls, 0); - }, + await estado.agregarEmisoraCustom( + emisoraDemo(uuid: 'nueva-x', nombre: 'X'), ); - test( - 'si ya existe un sidecar .corrupt no lo pisa, solo limpia el ' - 'archivo vivo (D5)', - () async { - final archivo = await _crearArchivoCustomRaw('{bad-nuevo'); - final sidecar = File('${archivo.path}.corrupt'); - await sidecar.writeAsString('contenido-previo-X'); + expect(estado.emisorasCustom.map((e) => e.uuid), contains('nueva-x')); + expect(espia.writeAsStringCalls, 0); + }); - final estado = EstadoRadio( - audio: FakeServicioAudio(), - favoritos: FakeServicioFavoritos(), - radio: FakeServicioRadio(), - servicioEcualizador: FakeServicioEcualizador(), - resolverArchivoCustom: () async => archivo, - iniciarAutomaticamente: false, - ); + test('si ya existe un sidecar .corrupt no lo pisa, solo limpia el ' + 'archivo vivo (D5)', () async { + final archivo = await _crearArchivoCustomRaw('{bad-nuevo'); + final sidecar = File('${archivo.path}.corrupt'); + await sidecar.writeAsString('contenido-previo-X'); - await estado.inicializar(); - - expect(await sidecar.readAsString(), 'contenido-previo-X'); - expect(await archivo.exists(), isFalse); - expect(estado.emisorasCustom, isEmpty); - }, - ); - }, - ); - - group( - 'EstadoRadio — Android Auto: snapshot en vivo y reconciliación ' - '(android-auto-media)', - () { - test( - 'empuja un snapshot actualizado a la fuente registrada cuando ' - 'cambian favoritos/custom/populares', - () async { - final fuenteAuto = _FuenteEmisorasAutoEspia(); - final archivo = await _crearArchivoCustom([ - emisoraDemo(uuid: 'custom-auto-1', nombre: 'Custom Auto'), - ]); - final emisoraFav = emisoraDemo(uuid: 'fav-auto-1', nombre: 'Fav Auto'); - final estado = EstadoRadio( - audio: FakeServicioAudio(), - favoritos: FakeServicioFavoritos(), - radio: FakeServicioRadio( - populares: [emisoraDemo(uuid: 'pop-auto-1', nombre: 'Pop Auto')], - ), - servicioEcualizador: FakeServicioEcualizador(), - resolverArchivoCustom: () async => archivo, - fuenteAuto: fuenteAuto, - iniciarAutomaticamente: false, - ); - - await estado.inicializar(); - - expect( - fuenteAuto.ultimoMisEmisoras?.map((e) => e.uuid), - contains('custom-auto-1'), - ); - expect( - fuenteAuto.ultimoTodas?.map((e) => e.uuid), - contains('pop-auto-1'), - ); - expect( - fuenteAuto.ultimoGrupos?.map((g) => g.id), - contains(GrupoFavoritos.sinAsignarId), - ); - - await estado.toggleFavorito(emisoraFav); - - expect( - fuenteAuto.ultimoFavoritos?.map((e) => e.uuid), - contains('fav-auto-1'), - ); - }, + final estado = EstadoRadio( + audio: FakeServicioAudio(), + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + resolverArchivoCustom: () async => archivo, + iniciarAutomaticamente: false, ); - test( - 'reconcilia _emisoraSeleccionada cuando la selección viene desde ' - 'el auto (no via reproducir())', - () async { - final audio = _AudioControlado(); - final estado = EstadoRadio( - audio: audio, - favoritos: FakeServicioFavoritos(), - radio: FakeServicioRadio(), - servicioEcualizador: FakeServicioEcualizador(), - resolverArchivoCustom: _archivoCustomVacio, - iniciarAutomaticamente: false, - ); - await estado.inicializar(); - final desdeCoche = emisoraDemo( - uuid: 'auto-selected', - nombre: 'Desde el auto', - ); + await estado.inicializar(); - audio.seleccionarDesdeAuto(desdeCoche); - await Future.delayed(Duration.zero); + expect(await sidecar.readAsString(), 'contenido-previo-X'); + expect(await archivo.exists(), isFalse); + expect(estado.emisorasCustom, isEmpty); + }); + }); - expect(estado.emisoraActual?.uuid, desdeCoche.uuid); - }, + group('EstadoRadio — Android Auto: snapshot en vivo y reconciliación ' + '(android-auto-media)', () { + test('empuja un snapshot actualizado a la fuente registrada cuando ' + 'cambian favoritos/custom/populares', () async { + final fuenteAuto = _FuenteEmisorasAutoEspia(); + final archivo = await _crearArchivoCustom([ + emisoraDemo(uuid: 'custom-auto-1', nombre: 'Custom Auto'), + ]); + final emisoraFav = emisoraDemo(uuid: 'fav-auto-1', nombre: 'Fav Auto'); + final estado = EstadoRadio( + audio: FakeServicioAudio(), + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio( + populares: [emisoraDemo(uuid: 'pop-auto-1', nombre: 'Pop Auto')], + ), + servicioEcualizador: FakeServicioEcualizador(), + resolverArchivoCustom: () async => archivo, + fuenteAuto: fuenteAuto, + iniciarAutomaticamente: false, ); - }, - ); + + await estado.inicializar(); + + expect( + fuenteAuto.ultimoMisEmisoras?.map((e) => e.uuid), + contains('custom-auto-1'), + ); + expect( + fuenteAuto.ultimoTodas?.map((e) => e.uuid), + contains('pop-auto-1'), + ); + expect( + fuenteAuto.ultimoGrupos?.map((g) => g.id), + contains(GrupoFavoritos.sinAsignarId), + ); + + await estado.toggleFavorito(emisoraFav); + + expect( + fuenteAuto.ultimoFavoritos?.map((e) => e.uuid), + contains('fav-auto-1'), + ); + }); + + test('reconcilia _emisoraSeleccionada cuando la selección viene desde ' + 'el auto (no via reproducir())', () async { + final audio = _AudioControlado(); + final estado = EstadoRadio( + audio: audio, + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + resolverArchivoCustom: _archivoCustomVacio, + iniciarAutomaticamente: false, + ); + await estado.inicializar(); + final desdeCoche = emisoraDemo( + uuid: 'auto-selected', + nombre: 'Desde el auto', + ); + + audio.seleccionarDesdeAuto(desdeCoche); + await Future.delayed(Duration.zero); + + expect(estado.emisoraActual?.uuid, desdeCoche.uuid); + }); + }); + + group('EstadoRadio — última emisora reproducida (feedback-pruebas #4)', () { + test('la última emisora reproducida sobrevive a una nueva instancia ' + '(reinicio) como emisoraActual DETENIDA -- no arranca audio, no ' + 'reproduce, sólo queda seleccionada', () async { + final emisora = emisoraDemo(uuid: 'last-1', nombre: 'Ultima FM'); + final estadoUno = EstadoRadio( + audio: FakeServicioAudio(), + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + resolverArchivoCustom: _archivoCustomVacio, + iniciarAutomaticamente: false, + ); + await estadoUno.inicializar(); + await estadoUno.reproducir(emisora); + await estadoUno.detenerReproduccion(); + // Lets the fire-and-forget persistence write settle before + // spinning up the "restart" instance. + await Future.delayed(Duration.zero); + + final audioDos = FakeServicioAudio(); + final estadoDos = EstadoRadio( + audio: audioDos, + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + resolverArchivoCustom: _archivoCustomVacio, + iniciarAutomaticamente: false, + ); + + await estadoDos.inicializar(); + + expect(estadoDos.emisoraActual?.uuid, emisora.uuid); + expect(estadoDos.emisoraActual?.nombre, emisora.nombre); + expect( + audioDos.estaSonando, + isFalse, + reason: 'restoring the last station must never auto-start audio', + ); + }); + + test('sin ninguna emisora previamente reproducida, emisoraActual sigue ' + 'siendo null tras inicializar (instalación nueva)', () async { + final estado = EstadoRadio( + audio: FakeServicioAudio(), + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + resolverArchivoCustom: _archivoCustomVacio, + iniciarAutomaticamente: false, + ); + + await estado.inicializar(); + + expect(estado.emisoraActual, isNull); + }); + + test('una emisora seleccionada desde el auto (fuera de reproducir()) ' + 'también se recuerda para la próxima instancia', () async { + final audio = _AudioControlado(); + final estadoUno = EstadoRadio( + audio: audio, + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + resolverArchivoCustom: _archivoCustomVacio, + iniciarAutomaticamente: false, + ); + await estadoUno.inicializar(); + final desdeCoche = emisoraDemo( + uuid: 'auto-remembered', + nombre: 'Recordada desde el auto', + ); + audio.seleccionarDesdeAuto(desdeCoche); + await Future.delayed(Duration.zero); + + final estadoDos = EstadoRadio( + audio: FakeServicioAudio(), + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + resolverArchivoCustom: _archivoCustomVacio, + iniciarAutomaticamente: false, + ); + await estadoDos.inicializar(); + + expect(estadoDos.emisoraActual?.uuid, desdeCoche.uuid); + }); + }); } /// Spy [FuenteEmisorasAuto] that only records the last snapshot pushed by From fc866d7ec9f7a78e3fcac9ed6ec21138486699d2 Mon Sep 17 00:00:00 2001 From: freetlab Date: Thu, 30 Jul 2026 20:18:59 +0200 Subject: [PATCH 7/7] fix(buscar): correct the gaps between the filter row and the results Issue 3 (partial): the results area had no top gap against the filter row in one state and reused the horizontal constant for a vertical axis in another. Applies the 3-tier scale properly -- row tier for background-less placeholders, card tier for card states. The rest of the app's spacing review is still outstanding. --- lib/pantallas/pantalla_buscar.dart | 34 ++++++++++- test/pantallas/pantalla_buscar_test.dart | 73 ++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 3 deletions(-) diff --git a/lib/pantallas/pantalla_buscar.dart b/lib/pantallas/pantalla_buscar.dart index 940ab34..fec8a65 100644 --- a/lib/pantallas/pantalla_buscar.dart +++ b/lib/pantallas/pantalla_buscar.dart @@ -543,8 +543,17 @@ class _PantallaBuscarState extends State { if (estado.cargando) { // S5-R6: shimmer placeholders instead of a bare spinner, consistent // with the loading pattern used by the home grid. + // Issue 3 (feedback-pruebas): row tier (12), not card tier (16) -- + // these are background-less row placeholders, same tier as the real + // results below; the top inset is the standard section gap rather + // than the horizontal constant reused for a vertical axis. return Padding( - padding: const EdgeInsets.all(PluriLayout.horizontal), + padding: const EdgeInsets.fromLTRB( + PluriLayout.rowHorizontal, + PluriLayout.sectionGap, + PluriLayout.rowHorizontal, + PluriLayout.rowHorizontal, + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -579,8 +588,17 @@ class _PantallaBuscarState extends State { // across the app -- favorites, the discovery grid -- which this // item does not touch). final query = _controller.text.trim(); + // Issue 3 (feedback-pruebas): this card-tier state had NO top gap at + // all against the filter row above it -- the standard section gap + // now matches the other two mutually-exclusive results-area states + // (loading, populated) above. return Padding( - padding: const EdgeInsets.symmetric(horizontal: PluriLayout.horizontal), + padding: const EdgeInsets.fromLTRB( + PluriLayout.horizontal, + PluriLayout.sectionGap, + PluriLayout.horizontal, + 0, + ), child: _TarjetaSinResultados( titulo: sinFiltros @@ -616,7 +634,17 @@ class _PantallaBuscarState extends State { return ListView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), - padding: const EdgeInsets.all(PluriLayout.horizontal), + // Issue 3 (feedback-pruebas): row tier (12), not card tier (16) -- + // `FilaEmisoraPlana` rows are documented as "flat, background-less" + // (audit 6.5) but this padding never got updated to match when Tier 1 + // introduced the 3-tier scale. The top inset is the standard section + // gap, not the horizontal constant reused for a vertical axis. + padding: const EdgeInsets.fromLTRB( + PluriLayout.rowHorizontal, + PluriLayout.sectionGap, + PluriLayout.rowHorizontal, + PluriLayout.rowHorizontal, + ), itemCount: total, itemBuilder: (context, i) { if (i >= resultados.length) { diff --git a/test/pantallas/pantalla_buscar_test.dart b/test/pantallas/pantalla_buscar_test.dart index 5d418be..5358510 100644 --- a/test/pantallas/pantalla_buscar_test.dart +++ b/test/pantallas/pantalla_buscar_test.dart @@ -15,6 +15,7 @@ import 'package:pluriwave/servicios/servicio_audio.dart'; import 'package:pluriwave/tema/pluriwave_theme.dart'; import 'package:pluriwave/tema/pluriwave_tokens.dart'; import 'package:pluriwave/widgets/fila_emisora_plana.dart'; +import 'package:pluriwave/widgets/pluri_layout.dart'; import 'package:pluriwave/widgets/tarjeta_emisora.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -835,6 +836,78 @@ void main() { ); }); + testWidgets('issue 3 (feedback-pruebas): the results list uses row-tier ' + 'horizontal padding (12), not the card-tier constant this "flat, ' + 'background-less row" was documented as needing but never got', ( + tester, + ) async { + _setLargeSurfaceSize(tester); + final estado = _crearEstado( + radio: FakeServicioRadio( + busqueda: [emisoraDemo(uuid: 'r-1', nombre: 'Radio Uno')], + ), + ); + addTearDown(estado.dispose); + await tester.runAsync(estado.inicializar); + + await tester.pumpWidget(_conProviders(estado, _testApp())); + await _pumpStableFrame(tester); + await tester.enterText(find.byType(SearchBar), 'radio'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await _pumpStableFrame(tester); + + final fila = find.byType(FilaEmisoraPlana); + expect( + tester.getTopLeft(fila).dx, + PluriLayout.rowHorizontal, + reason: + 'issue 3: background-less rows are row tier (12), not card ' + 'tier (16)', + ); + }); + + testWidgets('issue 3 (feedback-pruebas): the results list is topped by the ' + 'standard section gap, not the horizontal-inset constant reused for ' + 'a vertical axis', (tester) async { + _setLargeSurfaceSize(tester); + final estado = _crearEstado( + radio: FakeServicioRadio( + busqueda: [emisoraDemo(uuid: 'r-1', nombre: 'Radio Uno')], + ), + ); + addTearDown(estado.dispose); + await tester.runAsync(estado.inicializar); + + await tester.pumpWidget(_conProviders(estado, _testApp())); + await _pumpStableFrame(tester); + await tester.enterText(find.byType(SearchBar), 'radio'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await _pumpStableFrame(tester); + + // Reads the structural padding directly, rather than measuring a + // gap between two rendered widgets — the count row's own height is + // dictated by its taller PopupMenuButton (48dp touch target), so a + // position-based gap measurement against the count TEXT specifically + // would be thrown off by that unrelated vertical centring. + // + // Scoped to `shrinkWrap: true` — the OUTER page ListView is ALSO an + // ancestor of every `FilaEmisoraPlana`, but only `_resultados`'s OWN + // inner `ListView.builder` sets `shrinkWrap`. + final listaResultados = tester.widget( + find.byWidgetPredicate((w) => w is ListView && w.shrinkWrap), + ); + final padding = listaResultados.padding as EdgeInsets; + + expect( + padding.top, + PluriLayout.sectionGap, + reason: + 'issue 3: the results list must use the dedicated vertical ' + 'section gap above its first row, not the horizontal (16) ' + 'constant reused for a vertical axis', + ); + }); + testWidgets( 'tapping the favourite toggle on a search result adds it to favorites', (tester) async {