Files
pluriwave/lib/widgets/pluri_sleep_timer_sheet.dart
FreeTLab c6ab295c54 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.
2026-07-30 19:14:15 +02:00

267 lines
10 KiB
Dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../estado/estado_radio.dart';
import '../l10n/gen/app_localizations.dart';
import 'pluri_layout.dart';
/// S1 (Tier 1 visual fidelity): extracted from `app.dart`'s old
/// `_mostrarTimerDialog`, which only the single global `AppBar` could reach.
/// Now that each root draws its own [PluriRootHeader] instead, this is a
/// free function any of them can call directly with their own
/// `BuildContext` — the sleep-timer feature stays reachable from every tab
/// with no behaviour change.
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<EstadoRadio>(
builder:
(ctx, estado, _) => SafeArea(
child: Padding(
padding: PluriLayout.sheetPadding,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(ctx).sleepTimer,
style: Theme.of(ctx).textTheme.titleLarge,
),
const SizedBox(height: PluriLayout.sectionGap),
Text(
AppLocalizations.of(ctx).sleepTimerDescription,
style: Theme.of(ctx).textTheme.bodySmall,
),
const SizedBox(height: PluriLayout.panelGap),
if (estado.timer.activo)
StreamBuilder<Duration>(
stream: estado.timer.tiempoRestanteStream,
builder: (ctx, snap) {
final restante =
snap.data ?? estado.timer.tiempoRestante;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
_formatearDuracionTimer(
AppLocalizations.of(ctx),
restante,
),
style: Theme.of(ctx).textTheme.headlineMedium,
),
const SizedBox(height: PluriLayout.compactGap),
FilledButton.tonal(
onPressed: () {
estado.cancelarTimer();
Navigator.pop(ctx);
},
child: Text(
AppLocalizations.of(ctx).cancelTimer,
),
),
],
);
},
)
else
Wrap(
spacing: PluriLayout.compactGap,
runSpacing: PluriLayout.compactGap,
children: [
for (final segundos
in estado.timerSuenoPresetsSegundos)
ActionChip(
label: Text(
_formatearDuracionTimer(
AppLocalizations.of(ctx),
Duration(seconds: segundos),
),
),
// Issue 2 (feedback-pruebas): no longer pops
// the sheet -- `estado.iniciarTimerDuracion`
// notifies this `Consumer<EstadoRadio>`,
// 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),
label: Text(AppLocalizations.of(ctx).optionOther),
onPressed: () async {
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);
},
),
],
),
],
),
),
),
),
);
}
Future<Duration?> _pedirDuracionPersonalizada(BuildContext context) {
return showModalBottomSheet<Duration>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (ctx) => const _TimerPersonalizadoSheet(),
);
}
String _formatearDuracionTimer(AppLocalizations l10n, Duration duracion) {
final horas = duracion.inHours;
final minutos = duracion.inMinutes.remainder(60);
final segundos = duracion.inSeconds.remainder(60);
if (horas > 0) {
return l10n.durationHoursMinutesSeconds(
horas,
minutos.toString().padLeft(2, '0'),
segundos.toString().padLeft(2, '0'),
);
}
if (minutos > 0) {
return segundos == 0
? l10n.durationMinutesOnly(minutos)
: l10n.durationMinutesSeconds(
minutos,
segundos.toString().padLeft(2, '0'),
);
}
return l10n.durationSecondsOnly(segundos);
}
class _TimerPersonalizadoSheet extends StatefulWidget {
const _TimerPersonalizadoSheet();
@override
State<_TimerPersonalizadoSheet> createState() =>
_TimerPersonalizadoSheetState();
}
class _TimerPersonalizadoSheetState extends State<_TimerPersonalizadoSheet> {
final _horasCtrl = TextEditingController();
final _minutosCtrl = TextEditingController(text: '15');
final _segundosCtrl = TextEditingController();
bool _guardarPreset = true;
@override
void dispose() {
_horasCtrl.dispose();
_minutosCtrl.dispose();
_segundosCtrl.dispose();
super.dispose();
}
int _leer(TextEditingController ctrl) => int.tryParse(ctrl.text.trim()) ?? 0;
Future<void> _confirmar() async {
final duracion = Duration(
hours: _leer(_horasCtrl),
minutes: _leer(_minutosCtrl),
seconds: _leer(_segundosCtrl),
);
if (duracion <= Duration.zero) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context).durationGreaterThanZero),
),
);
return;
}
if (_guardarPreset) {
await context.read<EstadoRadio>().agregarTimerSuenoPreset(duracion);
}
if (mounted) Navigator.pop(context, duracion);
}
@override
Widget build(BuildContext context) {
final bottom = MediaQuery.viewInsetsOf(context).bottom;
return SafeArea(
child: Padding(
padding: EdgeInsets.fromLTRB(18, 0, 18, 18 + bottom),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
AppLocalizations.of(context).customDurationTitle,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: PluriLayout.sectionGap),
Row(
children: [
Expanded(
child: _campoTiempo(
_horasCtrl,
AppLocalizations.of(context).hoursLabel,
),
),
const SizedBox(width: PluriLayout.compactGap),
Expanded(
child: _campoTiempo(
_minutosCtrl,
AppLocalizations.of(context).minutesLabel,
),
),
const SizedBox(width: PluriLayout.compactGap),
Expanded(
child: _campoTiempo(
_segundosCtrl,
AppLocalizations.of(context).secondsLabel,
),
),
],
),
const SizedBox(height: PluriLayout.compactGap),
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
title: Text(AppLocalizations.of(context).saveQuickAccess),
value: _guardarPreset,
onChanged: (value) => setState(() => _guardarPreset = value),
),
const SizedBox(height: PluriLayout.sectionGap),
FilledButton.icon(
icon: const Icon(Icons.bedtime_rounded),
label: Text(AppLocalizations.of(context).startTimer),
onPressed: _confirmar,
),
],
),
),
);
}
Widget _campoTiempo(TextEditingController controller, String label) {
return TextField(
controller: controller,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
);
}
}