The prototype (t4) draws no global app bar anywhere: every root paints a plain ~56px title row inside its own content instead (Alarmas line 325, Ajustes line 511, Explorar line 641). app.dart wrapped every tab in PluriWaveScaffold(appBar: AppBar(title: Text(appTitle), ...)), adding 56dp of chrome and a "PluriWave" title the prototype never shows. Add PluriRootHeader, a shared 56px title-row widget reused by all 5 roots. Extract app.dart's old _mostrarTimerDialog (only reachable from the removed AppBar action) into a free function, showPluriSleepTimerSheet, so every root's header can open the same sheet directly and the sleep-timer feature stays reachable from every tab with no behaviour change. S1, Tier 1 visual-fidelity pass (audit id 2521).
254 lines
9.2 KiB
Dart
254 lines
9.2 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,
|
|
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),
|
|
),
|
|
),
|
|
onPressed: () {
|
|
estado.iniciarTimerDuracion(
|
|
Duration(seconds: segundos),
|
|
);
|
|
Navigator.pop(ctx);
|
|
},
|
|
),
|
|
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;
|
|
estado.iniciarTimerDuracion(duracion);
|
|
Navigator.pop(ctx);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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(),
|
|
),
|
|
);
|
|
}
|
|
}
|