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<EstadoRadio>
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.
This commit is contained in:
2026-07-30 19:14:15 +02:00
parent e75f010b98
commit c6ab295c54
2 changed files with 204 additions and 7 deletions
+20 -7
View File
@@ -14,6 +14,13 @@ import 'pluri_layout.dart';
void showPluriSleepTimerSheet(BuildContext context) { void showPluriSleepTimerSheet(BuildContext context) {
showModalBottomSheet( showModalBottomSheet(
context: context, 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, showDragHandle: true,
builder: builder:
(ctx) => Consumer<EstadoRadio>( (ctx) => Consumer<EstadoRadio>(
@@ -79,12 +86,17 @@ void showPluriSleepTimerSheet(BuildContext context) {
Duration(seconds: segundos), Duration(seconds: segundos),
), ),
), ),
onPressed: () { // Issue 2 (feedback-pruebas): no longer pops
estado.iniciarTimerDuracion( // the sheet -- `estado.iniciarTimerDuracion`
Duration(seconds: segundos), // notifies this `Consumer<EstadoRadio>`,
); // which swaps straight to the countdown
Navigator.pop(ctx); // 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( ActionChip(
avatar: const Icon(Icons.tune_rounded, size: 18), avatar: const Icon(Icons.tune_rounded, size: 18),
@@ -93,8 +105,9 @@ void showPluriSleepTimerSheet(BuildContext context) {
final duracion = final duracion =
await _pedirDuracionPersonalizada(ctx); await _pedirDuracionPersonalizada(ctx);
if (duracion == null || !ctx.mounted) return; if (duracion == null || !ctx.mounted) return;
// Issue 2: same as above -- stays open on
// the countdown view rather than closing.
estado.iniciarTimerDuracion(duracion); estado.iniciarTimerDuracion(duracion);
Navigator.pop(ctx);
}, },
), ),
], ],
@@ -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<EstadoRadio>.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<ActionChip>(
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<FilledButton>(
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<FilledButton>(
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();
},
);
}