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.
267 lines
10 KiB
Dart
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(),
|
|
),
|
|
);
|
|
}
|
|
}
|