From c6ab295c54e40b3bcf52b9ec80bb2f271d91009d Mon Sep 17 00:00:00 2001 From: freetlab Date: Thu, 30 Jul 2026 19:14:15 +0200 Subject: [PATCH] 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(); + }, + ); +}